From d5dc2af4923cace38bca14d4fce6f27ae9c50dbe Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 15:27:38 +0300 Subject: [PATCH 01/23] Added a Modules view: browse build.cfengine.com modules and open their pages An activity bar on the left switches the side bar between Blocks and files and Modules. Modules lists the CFEngine Build index (searched, filtered by tag), and a module's page shows its README, links, stats and versions from build.cfengine.com's module data. Adding a module isn't saved yet. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- SECURITY-CHECKLIST.md | 4 +- src/main/index.ts | 6 + src/main/modules.ts | 102 ++++++ src/preload/api.d.ts | 19 ++ src/preload/index.ts | 4 + src/renderer/index.html | 2 +- src/renderer/src/components/ActivityBar.tsx | 68 ++++ .../src/components/modules/ModulePage.tsx | 256 ++++++++++++++ .../src/components/modules/ModuleRow.tsx | 184 +++++++++++ .../src/components/modules/ModulesView.tsx | 230 +++++++++++++ src/renderer/src/modules/catalog.test.ts | 45 +++ src/renderer/src/modules/catalog.ts | 166 ++++++++++ .../src/modules/moduleDetails.test.ts | 38 +++ src/renderer/src/modules/moduleDetails.ts | 138 ++++++++ src/renderer/src/modules/useModuleCatalog.ts | 53 +++ src/renderer/src/modules/useModuleDetails.ts | 54 +++ src/renderer/src/pages/ProjectView.tsx | 311 +++++++++--------- src/renderer/src/pages/SideBar.tsx | 44 +++ src/renderer/src/pages/menuCommands.test.ts | 2 +- src/renderer/src/pages/menuCommands.ts | 12 +- src/renderer/src/pages/useProjectModules.ts | 58 ++++ src/renderer/src/pages/useSideBar.ts | 51 +++ src/renderer/src/store/index.ts | 2 + src/renderer/src/store/modulesSlice/index.ts | 29 ++ 24 files changed, 1726 insertions(+), 152 deletions(-) create mode 100644 src/main/modules.ts create mode 100644 src/renderer/src/components/ActivityBar.tsx create mode 100644 src/renderer/src/components/modules/ModulePage.tsx create mode 100644 src/renderer/src/components/modules/ModuleRow.tsx create mode 100644 src/renderer/src/components/modules/ModulesView.tsx create mode 100644 src/renderer/src/modules/catalog.test.ts create mode 100644 src/renderer/src/modules/catalog.ts create mode 100644 src/renderer/src/modules/moduleDetails.test.ts create mode 100644 src/renderer/src/modules/moduleDetails.ts create mode 100644 src/renderer/src/modules/useModuleCatalog.ts create mode 100644 src/renderer/src/modules/useModuleDetails.ts create mode 100644 src/renderer/src/pages/SideBar.tsx create mode 100644 src/renderer/src/pages/useProjectModules.ts create mode 100644 src/renderer/src/pages/useSideBar.ts create mode 100644 src/renderer/src/store/modulesSlice/index.ts diff --git a/SECURITY-CHECKLIST.md b/SECURITY-CHECKLIST.md index c4d8796..a7bfdea 100644 --- a/SECURITY-CHECKLIST.md +++ b/SECURITY-CHECKLIST.md @@ -15,13 +15,13 @@ on each push and pull request, and weekly: | # | Recommendation | Status | Evidence | | --- | --- | --- | --- | -| 1 | [Only load secure content](https://www.electronjs.org/docs/latest/tutorial/security#1-only-load-secure-content) | ✅ | The packaged app loads its own files only, from `app://bundle/` (item 18). Everything the main process fetches itself is https: the Enterprise hub (`checkedUrl` in [`src/main/hub.ts`](src/main/hub.ts) refuses anything else), the masterfiles versions index (`VERSIONS_URL` in [`src/main/project.ts`](src/main/project.ts)). `http://` appears only for the development server on localhost. The exception is a project's git remote: the user's choice of URL (`REMOTE_URL` in [`src/main/deploy.ts`](src/main/deploy.ts) also takes `http://`, `git://`, `file://` and local paths), fetched and pushed by the system's git. | +| 1 | [Only load secure content](https://www.electronjs.org/docs/latest/tutorial/security#1-only-load-secure-content) | ✅ | The packaged app loads its own files only, from `app://bundle/` (item 18). Everything the main process fetches itself is https: the Enterprise hub (`checkedUrl` in [`src/main/hub.ts`](src/main/hub.ts) refuses anything else), the masterfiles versions index (`VERSIONS_URL` in [`src/main/project.ts`](src/main/project.ts)), the CFEngine Build module index and module details (`INDEX_URL`, `DETAILS_URL` in [`src/main/modules.ts`](src/main/modules.ts); a README from them becomes plain data (`readmeTree`: allowlisted tags, http(s) links, no attributes) rendered as React elements, never as HTML). `http://` appears only for the development server on localhost. The exception is a project's git remote: the user's choice of URL (`REMOTE_URL` in [`src/main/deploy.ts`](src/main/deploy.ts) also takes `http://`, `git://`, `file://` and local paths), fetched and pushed by the system's git. | | 2 | [Do not enable Node.js integration for remote content](https://www.electronjs.org/docs/latest/tutorial/security#2-do-not-enable-nodejs-integration-for-remote-content) | ✅ | `nodeIntegration` is never enabled (`createWindow` in [`src/main/index.ts`](src/main/index.ts)); the running page has no `require` or `process`. | | 3 | [Enable context isolation](https://www.electronjs.org/docs/latest/tutorial/security#3-enable-context-isolation) | ✅ | `contextIsolation: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)). | | 4 | [Enable process sandboxing](https://www.electronjs.org/docs/latest/tutorial/security#4-enable-process-sandboxing) | ✅ | `sandbox: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)); no `--no-sandbox`. | | 5 | [Handle session permission requests from remote content](https://www.electronjs.org/docs/latest/tutorial/security#5-handle-session-permission-requests-from-remote-content) | ✅ | `setPermissionRequestHandler` and `setPermissionCheckHandler` in [`src/main/index.ts`](src/main/index.ts) grant only `clipboard-sanitized-write` (Copy in Generated Policy) and deny the rest, whether requested or only checked. | | 6 | [Do not disable `webSecurity`](https://www.electronjs.org/docs/latest/tutorial/security#6-do-not-disable-websecurity) | ✅ | Never set; the running window reports `webSecurity: true`. | -| 7 | [Define a Content Security Policy](https://www.electronjs.org/docs/latest/tutorial/security#7-define-a-content-security-policy) | ✅ | The `` in [`src/renderer/index.html`](src/renderer/index.html): `default-src 'self'`, `script-src 'self'` (no inline scripts, no `eval`). `style-src` allows `'unsafe-inline'`, which Emotion/MUI need for their injected styles. | +| 7 | [Define a Content Security Policy](https://www.electronjs.org/docs/latest/tutorial/security#7-define-a-content-security-policy) | ✅ | The `` in [`src/renderer/index.html`](src/renderer/index.html): `default-src 'self'`, `script-src 'self'` (no inline scripts, no `eval`). `style-src` allows `'unsafe-inline'`, which Emotion/MUI need for their injected styles. `img-src` adds GitHub's hosts (`github.com`, `raw.github.com`, `*.githubusercontent.com`) for module READMEs' images and maintainers' avatars; images elsewhere are shown as links (`githubImage` in [`src/renderer/src/modules/moduleDetails.ts`](src/renderer/src/modules/moduleDetails.ts)). | | 8 | [Do not enable `allowRunningInsecureContent`](https://www.electronjs.org/docs/latest/tutorial/security#8-do-not-enable-allowrunninginsecurecontent) | ✅ | Never set; the running window reports `false`. | | 9 | [Do not enable experimental features](https://www.electronjs.org/docs/latest/tutorial/security#9-do-not-enable-experimental-features) | ✅ | Never set; the running window reports `false`. | | 10 | [Do not use `enableBlinkFeatures`](https://www.electronjs.org/docs/latest/tutorial/security#10-do-not-use-enableblinkfeatures) | ✅ | Never set. | diff --git a/src/main/index.ts b/src/main/index.ts index 3b871b1..3cd3888 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,7 @@ import { compilePolicy, formatPolicy, lintCustom, warmUpSidecar } from './backen import { registerDeployHandlers } from './deploy'; import { registerHubHandlers } from './hub'; import { registerMcpHandlers } from './mcp'; +import { registerModuleHandlers } from './modules'; import { clearRecentProjects, getRecentProjects, onRecentsChanged, registerProjectHandlers } from './project'; import { registerTestEnvHandlers } from './testenv'; @@ -198,6 +199,10 @@ function buildApplicationMenu(mainWindow: BrowserWindow, recents: RecentProject[ click: command(`tab-${index}`) })), { type: 'separator' }, + { label: 'Toggle Side Bar', accelerator: 'CmdOrCtrl+B', enabled: hasProject, click: command('sidebar-toggle') }, + { label: 'Blocks and Files', accelerator: 'Shift+CmdOrCtrl+E', enabled: hasProject, click: command('view-policy') }, + { label: 'Modules', accelerator: 'Shift+CmdOrCtrl+X', enabled: hasProject, click: command('view-modules') }, + { type: 'separator' }, { label: 'Zoom In', accelerator: 'CmdOrCtrl+=', registerAccelerator: false, enabled: hasProject, click: command('zoomIn') }, { label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', registerAccelerator: false, enabled: hasProject, click: command('zoomOut') }, { label: 'Actual Size', accelerator: 'CmdOrCtrl+0', registerAccelerator: false, enabled: hasProject, click: command('zoomReset') }, @@ -365,6 +370,7 @@ app.whenReady().then(() => { }); registerProjectHandlers(isTrustedFrame); + registerModuleHandlers(isTrustedFrame); registerTestEnvHandlers(isTrustedFrame); registerDeployHandlers(isTrustedFrame); registerHubHandlers(isTrustedFrame); diff --git a/src/main/modules.ts b/src/main/modules.ts new file mode 100644 index 0000000..cd9f426 --- /dev/null +++ b/src/main/modules.ts @@ -0,0 +1,102 @@ +import { type WebFrameMain, app, ipcMain } from 'electron'; +import { promises as fs } from 'fs'; +import { join } from 'path'; + +import type { ModuleDetailsResult, ModuleIndexResult } from '../preload/api'; + +// The CFEngine Build index (build.cfengine.com): every module cfbs can add. +const INDEX_URL = 'https://raw.githubusercontent.com/cfengine/build-index/master/cfbs.json'; +const INDEX_TIMEOUT_MS = 15_000; + +const cachePath = () => join(app.getPath('userData'), 'module-index.json'); + +interface CachedIndex { + fetchedAt: string; + index: Record; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +async function readCache(): Promise { + try { + const cached: unknown = JSON.parse(await fs.readFile(cachePath(), 'utf8')); + return isRecord(cached) && typeof cached.fetchedAt === 'string' && isRecord(cached.index) ? (cached as unknown as CachedIndex) : null; + } catch { + return null; + } +} + +async function download(): Promise { + const response = await fetch(INDEX_URL, { signal: AbortSignal.timeout(INDEX_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body: unknown = await response.json(); + if (!isRecord(body) || !isRecord(body.index)) throw new Error('not a cfbs index'); + const fresh = { fetchedAt: new Date().toISOString(), index: body.index }; + await fs.writeFile(cachePath(), JSON.stringify(fresh)); + return fresh; +} + +/** The index, downloaded once per app session (or on refresh); offline, the last copy downloaded. */ +let request: Promise | null = null; + +async function moduleIndex(): Promise { + try { + return { ...(await download()), offline: false }; + } catch (error) { + request = null; // try again next time + const message = error instanceof Error ? error.message : String(error); + const cached = await readCache(); + return cached ? { ...cached, offline: true, error: message } : { fetchedAt: null, index: null, offline: true, error: message }; + } +} + +// The website's data (what build.cfengine.com and Mission Portal show): per module and version, its +// README as HTML, author, downloads, dates and links. Downloaded once per session, cached. +const DETAILS_URL = 'https://build.cfengine.com/js/modules.json'; +const MODULE_NAME = /^[a-z0-9][a-z0-9._-]{0,100}$/i; +const detailsPath = () => join(app.getPath('userData'), 'module-details.json'); + +let detailsRequest: Promise<{ error?: string; modules: Record | null; offline: boolean }> | null = null; + +async function allDetails() { + try { + const response = await fetch(DETAILS_URL, { signal: AbortSignal.timeout(INDEX_TIMEOUT_MS * 2) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const modules: unknown = await response.json(); + if (!isRecord(modules)) throw new Error('not the module list'); + await fs.writeFile(detailsPath(), JSON.stringify(modules)); + return { modules, offline: false }; + } catch (error) { + detailsRequest = null; + const message = error instanceof Error ? error.message : String(error); + const cached = await fs + .readFile(detailsPath(), 'utf8') + .then(text => JSON.parse(text) as unknown) + .catch(() => null); + return { modules: isRecord(cached) ? cached : null, offline: true, error: message }; + } +} + +/** One module's latest version, as the website has it (each version is an entry, the latest flagged). */ +async function moduleDetails(name: string): Promise { + const { error, modules, offline } = await (detailsRequest ??= allDetails()); + const versions = modules?.[name]; + if (!isRecord(versions)) return { details: null, offline, error: error ?? 'build.cfengine.com doesn’t list it' }; + const isLatest = ([version, entry]: [string, unknown]) => + isRecord(entry) && isRecord(entry.versions) && isRecord(entry.versions[version]) && entry.versions[version].latest === true; + const [, latest] = Object.entries(versions).find(isLatest) ?? Object.entries(versions).at(-1) ?? []; + return { details: isRecord(latest) ? latest : null, offline, error }; +} + +export function registerModuleHandlers(isTrustedFrame: (frame: WebFrameMain | null) => boolean): void { + ipcMain.handle('modules:index', (event, refresh: unknown) => { + if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); + if (refresh === true) request = null; + return (request ??= moduleIndex()); + }); + ipcMain.handle('modules:details', (event, name: unknown) => { + if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); + if (typeof name !== 'string' || !MODULE_NAME.test(name)) throw new Error('invalid module name'); + return moduleDetails(name); + }); +} diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 5a60a0d..c73647e 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -266,6 +266,21 @@ export interface GitStatus { export type OperationResult = ({ ok: true } & T) | { details: string; message: string; ok: false }; +/** The CFEngine Build module index: downloaded, or offline the last copy (none: index null). */ +export interface ModuleIndexResult { + error?: string; + fetchedAt: string | null; + index: Record | null; + offline: boolean; +} + +/** A module's details on build.cfengine.com (README as HTML, author, downloads, versions, links); null if unlisted. */ +export interface ModuleDetailsResult { + details: Record | null; + error?: string; + offline: boolean; +} + export interface MasterfilesVersions { latest: string; } @@ -325,6 +340,10 @@ declare global { getLayoutSettings: () => Promise; /** Newest 3.27.x masterfiles release (built-in fallback when offline). */ getMasterfilesVersions: () => Promise; + /** A module's details as build.cfengine.com shows them: README, author, downloads, versions, links. */ + getModuleDetails: (name: string) => Promise; + /** The CFEngine Build module index (once per session, or again with refresh). */ + getModuleIndex: (refresh?: boolean) => Promise; /** The file-system path of a File dropped onto the window. */ getPathForFile: (file: File) => string; /** The last few opened/created projects, most recent first. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 6645687..3b69ee8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,8 @@ import type { MasterfilesVersions, McpPermissions, McpStatus, + ModuleDetailsResult, + ModuleIndexResult, NativeEdit, OpenedProject, OperationResult, @@ -199,6 +201,8 @@ const api = { /** Newest 3.27.x masterfiles release (built-in fallback when offline). */ getMasterfilesVersions: (): Promise => invoke('project:masterfiles-versions'), + getModuleIndex: (refresh?: boolean): Promise => invoke('modules:index', refresh === true), + getModuleDetails: (name: string): Promise => invoke('modules:details', name), /** Checks whether a project folder can be created at parent/folderName. */ checkProjectTarget: (parent: string, folderName: string): Promise => invoke('project:check-target', { parent, folderName }), diff --git a/src/renderer/index.html b/src/renderer/index.html index dd7111f..7916d73 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,7 +7,7 @@ 'unsafe-inline' is required for Emotion/MUI injected styles. --> CFEngine Policy Builder diff --git a/src/renderer/src/components/ActivityBar.tsx b/src/renderer/src/components/ActivityBar.tsx new file mode 100644 index 0000000..3514a4b --- /dev/null +++ b/src/renderer/src/components/ActivityBar.tsx @@ -0,0 +1,68 @@ +import type { SvgIconComponent } from '@mui/icons-material'; +import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; +import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined'; +import { Badge, Box, ButtonBase, Tooltip } from '@mui/material'; + +export type SideView = 'modules' | 'policy'; + +const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform); +const shortcut = (key: string) => (isMac ? `⇧⌘${key}` : `Ctrl+Shift+${key}`); + +export const SIDE_VIEWS: { icon: SvgIconComponent; id: SideView; label: string; shortcut: string }[] = [ + { id: 'policy', label: 'Blocks and files', icon: WidgetsOutlinedIcon, shortcut: shortcut('E') }, + { id: 'modules', label: 'Modules', icon: ExtensionOutlinedIcon, shortcut: shortcut('X') } +]; + +interface ActivityBarProps { + // The open view; null while the side bar is collapsed. + active: SideView | null; + badges?: Partial>; + onSelect: (view: SideView) => void; +} + +/** The strip of view icons left of the side bar, as in VS Code: a click opens a view, or collapses the open one. */ +export function ActivityBar({ active, badges = {}, onSelect }: ActivityBarProps) { + return ( + + {SIDE_VIEWS.map(({ icon: Icon, id, label, shortcut: keys }) => { + const selected = active === id; + const count = badges[id] ?? 0; + return ( + + onSelect(id)} + sx={{ + width: 44, + height: 44, + borderLeft: '2px solid', + borderColor: selected ? 'primary.main' : 'transparent', + color: selected ? 'text.primary' : 'text.muted', + '&:hover': { color: 'text.primary' } + }} + > + + + + + + ); + })} + + ); +} diff --git a/src/renderer/src/components/modules/ModulePage.tsx b/src/renderer/src/components/modules/ModulePage.tsx new file mode 100644 index 0000000..b64f7e6 --- /dev/null +++ b/src/renderer/src/components/modules/ModulePage.tsx @@ -0,0 +1,256 @@ +import { type ReactNode, createElement, useState } from 'react'; + +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { Box, Button, CircularProgress, Link, Tab, Tabs, Typography } from '@mui/material'; + +import { type CatalogModule, sourceUrl } from '../../modules/catalog'; +import type { ReadmeNode } from '../../modules/moduleDetails'; +import type { ModuleDetailsState } from '../../modules/useModuleDetails'; +import type { ProjectModule } from '../../store/modulesSlice'; +import { KIND_LABELS, ModuleBadges } from './ModuleRow'; + +const readmeSx = { + fontSize: 14, + lineHeight: 1.6, + color: 'text.primary', + wordBreak: 'break-word', + '& h1, & h2, & h3, & h4': { fontWeight: 700, lineHeight: 1.3, mt: 3, mb: 1 }, + '& h1': { fontSize: 22 }, + '& h2': { fontSize: 18 }, + '& h3': { fontSize: 15 }, + '& h4': { fontSize: 14 }, + '& p, & ul, & ol, & dl, & table, & pre, & blockquote, & figure': { my: 1.25 }, + '& ul, & ol': { pl: 3 }, + '& dt': { fontWeight: 700 }, + '& dd': { ml: 3 }, + '& a': { color: 'primary.main' }, + '& code': { fontFamily: 'monospace', fontSize: 12.5, bgcolor: 'background.default', px: 0.5, borderRadius: '4px' }, + '& pre': { fontFamily: 'monospace', fontSize: 12.5, bgcolor: 'background.default', p: 1.5, borderRadius: '4px', overflowX: 'auto' }, + '& pre code': { bgcolor: 'transparent', p: 0 }, + '& table': { borderCollapse: 'collapse', display: 'block', overflowX: 'auto' }, + '& th, & td': { border: '1px solid', borderColor: 'divider', px: 1, py: 0.5, textAlign: 'left', verticalAlign: 'top' }, + '& blockquote': { borderLeft: '3px solid', borderColor: 'divider', pl: 1.5, color: 'text.muted', mx: 0 }, + '& hr': { border: 0, borderTop: '1px solid', borderColor: 'divider' }, + '& img': { maxWidth: '100%', height: 'auto', verticalAlign: 'middle' } +}; + +function SideHeading({ children }: { children: string }) { + return {children}; +} + +function Muted({ children }: { children: ReactNode }) { + return {children}; +} + +const external = { target: '_blank', rel: 'noreferrer' }; + +interface ModulePageProps { + // Its place in the project's build order, when added. + added?: { index: number; module: ProjectModule; total: number }; + backLabel: string; + module: CatalogModule; + needsInput: boolean; + onAdd: () => void; + onBack: () => void; + onOpenModule: (name: string) => void; + onRemove: () => void; + web: ModuleDetailsState; + // The dependencies adding it would bring that the project doesn't have yet. + wouldAdd: string[]; +} + +// The README tree as React elements: text stays text, links open outside the app. +function renderReadme(nodes: ReadmeNode[]): ReactNode[] { + return nodes.map((node, index) => { + if (typeof node === 'string') return node; + if (node.tag === 'img') return createElement('img', { key: index, src: node.src, alt: node.alt, loading: 'lazy' }); + const props = { key: index, ...(node.href ? { href: node.href, ...external } : {}) }; + return createElement(node.tag, props, ...(node.tag === 'br' || node.tag === 'hr' ? [] : renderReadme(node.children))); + }); +} + +function Description({ web }: { web: ModuleDetailsState }) { + if (web.loading) + return ( + + + Loading the description from build.cfengine.com… + + ); + if (!web.details?.readme.length) + return {web.details ? 'This module has no description.' : `Can’t load the description${web.error ? ` (${web.error})` : ''}.`}; + return {renderReadme(web.details.readme)}; +} + +/** A module's page, as on build.cfengine.com: description, links, stats, versions and what it needs. */ +export function ModulePage({ added, backLabel, module, needsInput, onAdd, onBack, onOpenModule, onRemove, web, wouldAdd }: ModulePageProps) { + const [tab, setTab] = useState(0); + const details = web.details; + const repo = module.repo?.startsWith('https://') ? module.repo.replace(/\/$/, '') : undefined; + const source = sourceUrl(module); + const links = [ + source && { label: 'Repository', href: source }, + repo?.startsWith('https://github.com/') && { label: 'Report issue', href: `${repo}/issues` }, + details?.documentation && { label: 'Documentation', href: details.documentation }, + details?.website && { label: 'Website', href: details.website }, + details?.discussionSearch && { label: 'Discussions', href: details.discussionSearch }, + { label: 'build.cfengine.com', href: `https://build.cfengine.com/modules/${encodeURIComponent(module.name)}/` } + ].filter((link): link is { href: string; label: string } => Boolean(link)); + + return ( + + + + + + + + + + {module.name} + + {module.version && {module.version}} + + {added ? ( + !added.module.addedBy && ( + + ) + ) : ( + + )} + + {module.description} + + {KIND_LABELS[module.kind]} + + + + {links.map(link => ( + + {link.label} ↗ + + ))} + + {added?.module.addedBy && Added for {added.module.addedBy}: it’s removed with it, unless another module needs it.} + {!added && wouldAdd.length > 0 && Adding it also adds {wouldAdd.join(', ')}.} + {module.kind === 'promise-type' && Adds a promise type for your own policy. Hosts need python3 to run it.} + + setTab(value)} + sx={{ mt: 2, borderBottom: '1px solid', borderColor: 'divider', minHeight: 36 }} + > + + + {module.input.length > 0 && } + + + {tab === 0 && } + {tab === 1 && + (module.dependencies.length === 0 ? ( + This module has no dependencies. + ) : ( + module.dependencies.map(name => ( + onOpenModule(name)} + sx={{ display: 'block', fontSize: 13, fontFamily: 'monospace', mb: 0.5 }} + > + {name} + + )) + ))} + {tab === 2 && ( + <> + {added ? 'Its questions, answered in the project:' : 'After adding, it asks:'} + {module.input.map((question, index) => ( + + {question.label ?? question.variable} + {question.question && ` — ${question.question}`} + + {' '} + ({question.type === 'list' ? 'a list' : 'text'}) + + + ))} + + )} + + + + + {details?.author && ( + <> + Maintainer + {details.author.image && ( + + )} + {details.author.url ? ( + + {details.author.name} + + ) : ( + {details.author.name} + )} + + )} + {(details?.downloads || details?.updated) && ( + <> + Module stats + {details.downloads && Total downloads: {details.downloads}} + {details.updated && Updated: {details.updated}} + + )} + {added && ( + <> + In this project + + Runs after your policy, {added.index + 1} of {added.total} modules. + + + )} + {details && details.versions.length > 0 && ( + <> + Versions + {details.versions.map(version => ( + + + {version.version} + + {version.date && ` · ${version.date}`} + {version.latest && ' · latest'} + + ))} + + )} + {module.tags.length > 0 && ( + <> + Tags + {module.tags.join(', ')} + + )} + {module.aliases.length > 0 && ( + <> + Also known as + {module.aliases.join(', ')} + + )} + {web.offline && details && Offline: shown from the last download.} + + + + + ); +} + +/** The open module's page over the center area, or else what it shows (`children`). */ +export function ModulePageOr({ children, page }: { children: ReactNode; page?: ModulePageProps }) { + return page ? : children; +} diff --git a/src/renderer/src/components/modules/ModuleRow.tsx b/src/renderer/src/components/modules/ModuleRow.tsx new file mode 100644 index 0000000..0d21cda --- /dev/null +++ b/src/renderer/src/components/modules/ModuleRow.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react'; + +import type { SvgIconComponent } from '@mui/icons-material'; +import CheckIcon from '@mui/icons-material/Check'; +import CodeIcon from '@mui/icons-material/Code'; +import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; +import LibraryBooksOutlinedIcon from '@mui/icons-material/LibraryBooksOutlined'; +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; +import TuneIcon from '@mui/icons-material/Tune'; +import { Box, Button, Chip, IconButton, ListItemText, Menu, MenuItem, Tooltip, Typography } from '@mui/material'; + +import { type CatalogModule, type ModuleKind, sourceUrl } from '../../modules/catalog'; + +export const KIND_ICONS: Record = { + configured: TuneIcon, + library: LibraryBooksOutlinedIcon, + policy: DescriptionOutlinedIcon, + 'promise-type': CodeIcon +}; + +export const KIND_LABELS: Record = { + configured: 'Configured', + library: 'Library', + policy: 'Policy', + 'promise-type': 'Promise type' +}; + +const chipSx = { height: 18, fontSize: 10, '& .MuiChip-label': { px: 0.75 } }; + +/** A module's badges: Experimental, Needs input, Promise type. */ +export function ModuleBadges({ module, needsInput }: { module: CatalogModule; needsInput: boolean }) { + return ( + <> + {module.experimental && } + {needsInput && } + {module.kind === 'promise-type' && } + + ); +} + +interface ModuleRowProps { + // In the catalog section: whether the project has it already. + added?: boolean; + // A dependency: the module it came with. + addedFor?: string; + alias?: string; + // In the project section (gear menu), else in the catalog (Add). + inProject: boolean; + module: CatalogModule; + needsInput: boolean; + onAdd: () => void; + onEditInput: () => void; + onRemove: () => void; + onSelect: () => void; + selected: boolean; +} + +function GearMenu({ addedFor, module, onEditInput, onRemove }: Pick) { + const [anchor, setAnchor] = useState(null); + const source = sourceUrl(module); + const close = () => setAnchor(null); + return ( + <> + { + event.stopPropagation(); + setAnchor(event.currentTarget); + }} + sx={{ p: 0.25 }} + > + + + event.stopPropagation()}> + {module.input.length > 0 && ( + { + close(); + onEditInput(); + }} + > + Edit input + + )} + {source && ( + + Open source ↗ + + )} + {addedFor ? ( + + + + ) : ( + { + close(); + onRemove(); + }} + sx={{ color: 'error.main' }} + > + Remove… + + )} + + + ); +} + +/** One module: kind icon, name, version and action; then its description and badges. */ +export function ModuleRow({ added, addedFor, alias, inProject, module, needsInput, onAdd, onEditInput, onRemove, onSelect, selected }: ModuleRowProps) { + const KindIcon = KIND_ICONS[module.kind]; + return ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(); + } + }} + sx={{ + display: 'flex', + gap: 0.75, + px: 1.25, + py: 0.75, + pl: addedFor ? 3.5 : 1.25, + borderRadius: '4px', + cursor: 'pointer', + bgcolor: selected ? 'action.selected' : undefined, + '&:hover': { bgcolor: selected ? 'action.selected' : 'background.default' } + }} + > + + + + + + {module.name} + + + {module.version && {module.version}} + {inProject ? ( + + ) : added ? ( + + Added + + ) : ( + + )} + + {addedFor ? ( + + added for {addedFor} + + ) : ( + <> + + {alias ? `also known as ${alias} · ` : ''} + {module.description} + + + + + + )} + + + ); +} diff --git a/src/renderer/src/components/modules/ModulesView.tsx b/src/renderer/src/components/modules/ModulesView.tsx new file mode 100644 index 0000000..6cecb95 --- /dev/null +++ b/src/renderer/src/components/modules/ModulesView.tsx @@ -0,0 +1,230 @@ +import { type ReactNode, useMemo, useState } from 'react'; + +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import SearchIcon from '@mui/icons-material/Search'; +import { + Box, + Button, + ButtonBase, + Checkbox, + Chip, + CircularProgress, + FormControlLabel, + IconButton, + InputAdornment, + Menu, + MenuItem, + TextField, + Typography +} from '@mui/material'; + +import { type CatalogFilter, type CatalogModule, TAG_CHIPS, filterCatalog } from '../../modules/catalog'; +import type { ModuleCatalog } from '../../modules/useModuleCatalog'; +import type { ProjectModule } from '../../store/modulesSlice'; +import { ModuleRow } from './ModuleRow'; + +const shortDate = (iso: string) => new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); + +// A module the index no longer lists still shows, by its name. +const unknownModule = (module: ProjectModule): CatalogModule => ({ + aliases: [], + dependencies: [], + description: 'Not in the module list', + experimental: false, + input: [], + kind: 'policy', + name: module.name, + supported: false, + tags: [], + version: module.version +}); + +function Section({ children, count, title }: { children: ReactNode; count: number; title: string }) { + const [collapsed, setCollapsed] = useState(false); + return ( + + setCollapsed(current => !current)} + aria-expanded={!collapsed} + sx={{ display: 'flex', width: '100%', alignItems: 'center', gap: 0.5, px: 1, py: 0.75, borderRadius: '4px', justifyContent: 'flex-start' }} + > + + + {title} ({count}) + + + {!collapsed && children} + + ); +} + +interface ModulesViewProps { + catalog: ModuleCatalog; + isNeedingInput: (name: string) => boolean; + onAdd: (name: string) => void; + onEditInput: (name: string) => void; + onRemove: (name: string) => void; + onSelect: (name: string) => void; + projectModules: ProjectModule[]; + selectedName: string | null; +} + +/** The CFEngine Build modules: the project's, then the whole index, searched and filtered by tag. */ +export function ModulesView({ catalog, isNeedingInput, onAdd, onEditInput, onRemove, onSelect, projectModules, selectedName }: ModulesViewProps) { + const [filter, setFilter] = useState({ search: '', supportedOnly: false, tag: null }); + const [showLibraries, setShowLibraries] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(null); + + const added = useMemo(() => new Set(projectModules.map(module => module.name)), [projectModules]); + const results = useMemo(() => filterCatalog(catalog.modules, filter, showLibraries), [catalog.modules, filter, showLibraries]); + const filtering = filter.search.trim() !== '' || filter.tag !== null || filter.supportedOnly; + + // The project's modules in build order, dependencies under the module they came with. + const inProject = useMemo(() => { + const entries = projectModules.map(module => ({ module, entry: catalog.byName.get(module.name) ?? unknownModule(module) })); + const kept = filtering + ? new Set( + filterCatalog( + entries.map(({ entry }) => entry), + filter, + true + ).map(match => match.module.name) + ) + : null; + const visible = entries.filter(({ entry }) => !kept || kept.has(entry.name)); + const tops = visible.filter(({ module }) => !module.addedBy || !visible.some(other => other.module.name === module.addedBy)); + return tops.flatMap(top => [top, ...visible.filter(({ module }) => module.addedBy === top.module.name && top.module.addedBy === undefined)]); + }, [projectModules, catalog.byName, filter, filtering]); + + const rowProps = (module: CatalogModule) => ({ + module, + selected: selectedName === module.name, + onSelect: () => onSelect(module.name), + onAdd: () => onAdd(module.name), + onEditInput: () => onEditInput(module.name), + onRemove: () => onRemove(module.name) + }); + + return ( + + + + Modules + setMenuAnchor(event.currentTarget)} sx={{ p: 0.25 }}> + + + setMenuAnchor(null)}> + { + setMenuAnchor(null); + catalog.refresh(); + }} + > + Refresh list + + { + setMenuAnchor(null); + setShowLibraries(current => !current); + }} + > + {showLibraries ? 'Hide libraries' : 'Show libraries'} + + + + setFilter(current => ({ ...current, search: event.target.value }))} + placeholder="Search modules…" + variant="standard" + fullWidth + size="small" + slotProps={{ + input: { + startAdornment: ( + + + + ) + } + }} + /> + + {[{ label: 'All', tag: null }, ...TAG_CHIPS].map(({ label, tag }) => ( + setFilter(current => ({ ...current, tag }))} + sx={{ height: 22, fontSize: 11 }} + /> + ))} + + setFilter(current => ({ ...current, supportedOnly: event.target.checked }))} + /> + } + label={Supported only} + sx={{ mt: 0.5, ml: -0.75 }} + /> + {catalog.offline && catalog.fetchedAt && ( + Offline · list from {shortDate(catalog.fetchedAt)} + )} + + + + {(projectModules.length > 0 || !filtering) && ( +
+ {inProject.length === 0 ? ( + + {filtering ? 'None of them match.' : 'No modules yet. Add one from the list below.'} + + ) : ( + inProject.map(({ module, entry }) => ( + + )) + )} +
+ )} +
+ {catalog.loading && catalog.modules.length === 0 ? ( + + + Loading the module list… + + ) : catalog.modules.length === 0 ? ( + + Can’t load the module list. Connect to the internet, then refresh. + + + ) : results.length === 0 ? ( + + No modules match{filter.search.trim() ? ` “${filter.search.trim()}”` : ''}. + + ) : ( + results.map(({ module, alias }) => ( + 0} + /> + )) + )} +
+
+
+ ); +} diff --git a/src/renderer/src/modules/catalog.test.ts b/src/renderer/src/modules/catalog.test.ts new file mode 100644 index 0000000..895e981 --- /dev/null +++ b/src/renderer/src/modules/catalog.test.ts @@ -0,0 +1,45 @@ +import { filterCatalog, modulesToAdd, modulesToRemove, parseIndex } from './catalog'; + +const index = { + 'delete-files': { description: 'Deletes files', tags: ['supported', 'management'], version: '2.0.0', input: [{ type: 'list', variable: 'files' }] }, + 'promise-type-git': { description: 'Manage git repos', tags: ['promise-type'], dependencies: ['library-for-promise-types-in-python'] }, + 'library-for-promise-types-in-python': { description: 'Library', tags: ['library'] }, + 'ssh-limits': { description: 'Limit SSH', tags: ['experimental', 'ssh'], dependencies: ['library-sshd-config'] }, + 'library-sshd-config': { description: 'sshd config', tags: ['library'] }, + 'git-old': { alias: 'promise-type-git' } +}; +const modules = parseIndex(index); +const byName = new Map(modules.map(module => [module.name, module])); +const names = (matches: { module: { name: string } }[]) => matches.map(match => match.module.name); + +describe('the module catalog', () => { + it('derives kinds and folds aliases into their module', () => { + expect(byName.get('delete-files')).toMatchObject({ kind: 'configured', supported: true }); + expect(byName.get('promise-type-git')).toMatchObject({ kind: 'promise-type', aliases: ['git-old'] }); + expect(byName.get('ssh-limits')).toMatchObject({ kind: 'policy', experimental: true }); + expect(byName.has('git-old')).toBe(false); + }); + + it('searches names, descriptions, tags and aliases; hides libraries unless asked', () => { + const all = { search: '', supportedOnly: false, tag: null }; + expect(names(filterCatalog(modules, all))).toEqual(['delete-files', 'promise-type-git', 'ssh-limits']); + expect(names(filterCatalog(modules, { ...all, tag: 'library' }))).toEqual(['library-for-promise-types-in-python', 'library-sshd-config']); + expect(names(filterCatalog(modules, { ...all, search: 'sshd' }))).toEqual(['library-sshd-config']); + expect(filterCatalog(modules, { ...all, search: 'old' })).toEqual([{ module: byName.get('promise-type-git'), alias: 'git-old' }]); + expect(names(filterCatalog(modules, { ...all, supportedOnly: true }))).toEqual(['delete-files']); + }); + + it('adds a module with its dependencies, and removes those nothing else needs', () => { + const added = modulesToAdd(byName, 'promise-type-git', []); + expect(added).toEqual([ + { name: 'promise-type-git', version: undefined }, + { name: 'library-for-promise-types-in-python', version: undefined, addedBy: 'promise-type-git' } + ]); + expect(modulesToAdd(byName, 'promise-type-git', added)).toEqual([]); + expect(modulesToRemove(byName, 'promise-type-git', added)).toEqual(['promise-type-git', 'library-for-promise-types-in-python']); + // Still needed by another module: it stays. + const both = [...added, { name: 'other' }]; + byName.set('other', { ...byName.get('delete-files')!, name: 'other', dependencies: ['library-for-promise-types-in-python'] }); + expect(modulesToRemove(byName, 'promise-type-git', both)).toEqual(['promise-type-git']); + }); +}); diff --git a/src/renderer/src/modules/catalog.ts b/src/renderer/src/modules/catalog.ts new file mode 100644 index 0000000..42871ac --- /dev/null +++ b/src/renderer/src/modules/catalog.ts @@ -0,0 +1,166 @@ +// The CFEngine Build index (build.cfengine.com) as the Modules view lists it. + +export type ModuleKind = 'configured' | 'library' | 'policy' | 'promise-type'; + +export interface ModuleInputQuestion { + label?: string; + question?: string; + type: string; + variable?: string; +} + +export interface CatalogModule { + aliases: string[]; + by?: string; + dependencies: string[]; + description: string; + experimental: boolean; + input: ModuleInputQuestion[]; + kind: ModuleKind; + name: string; + repo?: string; + subdirectory?: string; + supported: boolean; + tags: string[]; + version?: string; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []); +const text = (value: unknown) => (typeof value === 'string' ? value : undefined); + +function kindOf(tags: string[], input: unknown[]): ModuleKind { + if (tags.includes('promise-type')) return 'promise-type'; + if (tags.includes('library')) return 'library'; + return input.length ? 'configured' : 'policy'; +} + +/** The index's modules, A–Z; aliases (old names) are folded into the module they point to. */ +export function parseIndex(index: Record): CatalogModule[] { + const modules = new Map(); + for (const [name, entry] of Object.entries(index)) { + if (!isRecord(entry) || 'alias' in entry) continue; + const tags = strings(entry.tags); + const input = Array.isArray(entry.input) ? entry.input.filter(isRecord) : []; + modules.set(name, { + aliases: [], + by: text(entry.by)?.replace(/^https:\/\/github\.com\//, ''), + dependencies: strings(entry.dependencies), + description: text(entry.description) ?? '', + experimental: tags.includes('experimental'), + input: input.map(question => ({ + label: text(question.label), + question: text(question.question), + type: text(question.type) ?? 'string', + variable: text(question.variable) + })), + kind: kindOf(tags, input), + name, + repo: text(entry.repo), + subdirectory: text(entry.subdirectory), + supported: tags.includes('supported'), + tags, + version: text(entry.version) + }); + } + for (const [name, entry] of Object.entries(index)) { + const target = isRecord(entry) ? text(entry.alias) : undefined; + if (target) modules.get(target)?.aliases.push(name); + } + return [...modules.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Where a module's source lives: its repo, or its folder in it. */ +export const sourceUrl = (module: CatalogModule) => + module.repo && module.subdirectory ? `${module.repo.replace(/\/$/, '')}/tree/HEAD/${module.subdirectory}` : module.repo; + +// The tag chips: the frequent tags; the rest are found by searching. +export const TAG_CHIPS: { label: string; tag: string }[] = [ + { label: 'Security', tag: 'security' }, + { label: 'Inventory', tag: 'inventory' }, + { label: 'Compliance', tag: 'compliance' }, + { label: 'Management', tag: 'management' }, + { label: 'Promise types', tag: 'promise-type' }, + { label: 'Windows', tag: 'windows' }, + { label: 'SSH', tag: 'ssh' }, + { label: 'Libraries', tag: 'library' } +]; + +export interface CatalogFilter { + search: string; + supportedOnly: boolean; + tag: string | null; +} + +export interface CatalogMatch { + // The alias the search matched, when only an old name did. + alias?: string; + module: CatalogModule; +} + +const matchesTerm = (module: CatalogModule, term: string) => + module.name.toLowerCase().includes(term) || module.description.toLowerCase().includes(term) || module.tags.some(tag => tag.toLowerCase().includes(term)); + +// Tag chip, Supported and libraries; the search is matched by filterCatalog. +function keeps(module: CatalogModule, { supportedOnly, tag }: CatalogFilter, term: string, showLibraries: boolean) { + if ((tag && !module.tags.includes(tag)) || (supportedOnly && !module.supported)) return false; + if (module.kind !== 'library' || showLibraries || tag === 'library') return true; + return term !== '' && module.name.toLowerCase().includes(term); +} + +/** + * The modules a filter keeps: name, description, tags or an alias contain the search, the tag + * chip and Supported apply. Libraries only show with their chip or when searched for by name. + */ +export function filterCatalog(modules: CatalogModule[], filter: CatalogFilter, showLibraries = false): CatalogMatch[] { + const term = filter.search.trim().toLowerCase(); + const matches: CatalogMatch[] = []; + for (const module of modules) { + if (!keeps(module, filter, term, showLibraries)) continue; + if (!term || matchesTerm(module, term)) { + matches.push({ module }); + continue; + } + const alias = module.aliases.find(name => name.toLowerCase().includes(term)); + if (alias) matches.push({ module, alias }); + } + return matches; +} + +export interface AddedModule { + addedBy?: string; + name: string; + version?: string; +} + +/** What adding `name` adds: the module, then its dependencies (theirs too) not in the project yet. */ +export function modulesToAdd(catalog: Map, name: string, present: AddedModule[]): AddedModule[] { + const have = new Set(present.map(module => module.name)); + const added: AddedModule[] = []; + const visit = (current: string, addedBy?: string) => { + if (have.has(current)) return; + have.add(current); + added.push({ name: current, version: catalog.get(current)?.version, ...(addedBy ? { addedBy } : {}) }); + for (const dependency of catalog.get(current)?.dependencies ?? []) visit(dependency, name); + }; + visit(name); + return added; +} + +/** What removing `name` removes: it, and the dependencies it brought that nothing left needs. */ +export function modulesToRemove(catalog: Map, name: string, present: AddedModule[]): string[] { + const removed = new Set([name]); + let changed = true; + while (changed) { + changed = false; + const kept = present.filter(module => !removed.has(module.name)); + const needed = new Set(kept.flatMap(module => catalog.get(module.name)?.dependencies ?? [])); + for (const module of kept) { + if (module.addedBy && removed.has(module.addedBy) && !needed.has(module.name)) { + removed.add(module.name); + changed = true; + } + } + } + return present.filter(module => removed.has(module.name)).map(module => module.name); +} diff --git a/src/renderer/src/modules/moduleDetails.test.ts b/src/renderer/src/modules/moduleDetails.test.ts new file mode 100644 index 0000000..ef1ca89 --- /dev/null +++ b/src/renderer/src/modules/moduleDetails.test.ts @@ -0,0 +1,38 @@ +import { githubImage, parseDetails, readmeTree } from './moduleDetails'; + +describe('module details', () => { + it('keeps plain markup only: no scripts, attributes or unsafe links', () => { + const tree = readmeTree( + '

Use

See this and docs

kept text', + 'https://github.com/o/r/blob/abc/' + ); + expect(tree).toEqual([ + { tag: 'h2', children: ['Use'] }, + { tag: 'p', children: ['See ', 'this', ' and ', { tag: 'a', href: 'https://github.com/o/r/blob/abc/docs.md', children: ['docs'] }] }, + 'kept text' + ]); + }); + + it('shows GitHub images (a blob page as its raw file), links others', () => { + expect(githubImage('https://github.com/o/r/blob/abc/shot.png')).toBe('https://raw.githubusercontent.com/o/r/abc/shot.png'); + expect(githubImage('https://avatars.githubusercontent.com/u/1')).toBe('https://avatars.githubusercontent.com/u/1'); + expect(githubImage('https://example.com/a.png')).toBeUndefined(); + expect(readmeTree('ShotA', 'https://github.com/o/r/blob/abc/')).toEqual([ + { tag: 'img', src: 'https://raw.githubusercontent.com/o/r/abc/shot.png', alt: 'Shot', children: [] }, + { tag: 'a', href: 'https://example.com/a.png', children: ['🖼 A'] } + ]); + }); + + it('reads the website data: "None" links are none, versions newest first', () => { + const details = parseDetails({ + author: { name: 'Nick', url: 'https://github.com/nick', image: 'https://avatars.githubusercontent.com/u/2' }, + downloads: 89, + documentation: 'None', + versions: { '1.0.0': { date: 'Dec 21, 2022', latest: false }, '2.0.0': { date: 'Jun 30, 2023', latest: true } }, + readme: '

Hi

' + }); + expect(details).toMatchObject({ downloads: '89', documentation: undefined, readme: [{ tag: 'p', children: ['Hi'] }] }); + expect(details.author).toEqual({ name: 'Nick', url: 'https://github.com/nick', image: 'https://avatars.githubusercontent.com/u/2' }); + expect(details.versions.map(version => version.version)).toEqual(['2.0.0', '1.0.0']); + }); +}); diff --git a/src/renderer/src/modules/moduleDetails.ts b/src/renderer/src/modules/moduleDetails.ts new file mode 100644 index 0000000..221e285 --- /dev/null +++ b/src/renderer/src/modules/moduleDetails.ts @@ -0,0 +1,138 @@ +// A module's details as build.cfengine.com has them (its js/modules.json, what Mission Portal shows). + +export interface ModuleVersion { + date?: string; + latest: boolean; + version: string; +} + +export interface ModuleWebDetails { + author?: { image?: string; name: string; url?: string }; + discussion?: string; + discussionSearch?: string; + documentation?: string; + downloads?: string; + // The README, as a tree of allowlisted tags and text: rendered as React elements, never as HTML. + readme: ReadmeNode[]; + updated?: string; + versions: ModuleVersion[]; + website?: string; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +// The data writes a missing link as "None". +const text = (value: unknown) => (typeof value === 'string' && value !== '' && value !== 'None' ? value : undefined); +const webUrl = (value: unknown) => { + const url = text(value); + return url && /^https?:\/\//.test(url) ? url : undefined; +}; + +const avatar = (value: unknown) => { + const url = webUrl(value); + return url ? githubImage(url) : undefined; +}; + +export function parseDetails(details: Record, base?: string): ModuleWebDetails { + const author = isRecord(details.author) ? details.author : undefined; + const versions = isRecord(details.versions) ? details.versions : {}; + return { + author: author && text(author.name) ? { name: text(author.name)!, url: webUrl(author.url), image: avatar(author.image) } : undefined, + discussion: webUrl(details.discussion), + discussionSearch: webUrl(details.discussionSearch), + documentation: webUrl(details.documentation), + downloads: typeof details.downloads === 'number' ? String(details.downloads) : text(details.downloads), + readme: readmeTree(text(details.readme) ?? '', base), + updated: text(details.updated), + versions: Object.entries(versions) + .map(([version, info]) => ({ version, date: isRecord(info) ? text(info.date) : undefined, latest: isRecord(info) && info.latest === true })) + .reverse(), + website: webUrl(details.website) + }; +} + +/** A README element: an allowlisted tag (links with their http(s) target) and its content. */ +export interface ReadmeElement { + alt?: string; + children: ReadmeNode[]; + href?: string; + // An image's source: only from GitHub, the hosts the app's content policy allows. + src?: string; + tag: string; +} +export type ReadmeNode = ReadmeElement | string; + +// What a README keeps; anything else is replaced by its content, and these go with theirs. +export const README_TAGS = new Set( + 'a b blockquote br code dd div dl dt em figcaption figure h1 h2 h3 h4 h5 h6 hr i li ol p pre span strong table tbody td th thead tr ul'.split(' ') +); +const DROPPED = new Set([ + 'script', + 'style', + 'iframe', + 'object', + 'embed', + 'form', + 'input', + 'button', + 'textarea', + 'select', + 'svg', + 'math', + 'template', + 'link', + 'meta' +]); + +function absolute(href: string, base?: string): string | undefined { + try { + const url = new URL(href, base); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined; + } catch { + return undefined; + } +} + +// The image hosts the app's content policy allows (index.html's img-src): GitHub's. +const IMAGE_HOSTS = /^(github\.com|raw\.github\.com|[a-z0-9-]+\.githubusercontent\.com)$/; + +/** An image URL the app may load: GitHub's, a file shown in a repository (blob/) as its raw file. */ +export function githubImage(url: string): string | undefined { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' || !IMAGE_HOSTS.test(parsed.hostname)) return undefined; + const blob = /^\/([^/]+)\/([^/]+)\/blob\/(.+)$/.exec(parsed.pathname); + return parsed.hostname === 'github.com' && blob ? `https://raw.githubusercontent.com/${blob[1]}/${blob[2]}/${blob[3]}` : parsed.href; +} + +function fromElement(element: Element, base: string | undefined): ReadmeNode[] { + const tag = element.tagName.toLowerCase(); + if (DROPPED.has(tag)) return []; + if (tag === 'img') { + const src = absolute(element.getAttribute('src') ?? '', base); + const label = element.getAttribute('alt') || element.getAttribute('title') || 'Image'; + const image = src && githubImage(src); + if (image) return [{ tag: 'img', src: image, alt: label, children: [] }]; + // Elsewhere: the picture becomes a link to it. + return src ? [{ tag: 'a', href: src, children: [`🖼 ${label}`] }] : [label]; + } + const children = [...element.childNodes].flatMap(child => fromNode(child, base)); + if (!README_TAGS.has(tag)) return children; + if (tag !== 'a') return [{ tag, children }]; + const href = absolute(element.getAttribute('href') ?? '', base); + return href ? [{ tag, href, children }] : children; +} + +function fromNode(node: Node, base: string | undefined): ReadmeNode[] { + if (node.nodeType === Node.TEXT_NODE) return [node.textContent ?? '']; + if (node.nodeType === Node.ELEMENT_NODE) return fromElement(node as Element, base); + return []; +} + +/** + * A README's HTML as plain data: allowlisted tags without attributes (no classes, styles or + * handlers), links absolute (http/https only); the rest unwrapped, or dropped with its content. + * Parsing doesn't run anything. `base`: where relative links point (the module's repository folder). + */ +export function readmeTree(html: string, base?: string): ReadmeNode[] { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + return [...parsed.body.childNodes].flatMap(node => fromNode(node, base)); +} diff --git a/src/renderer/src/modules/useModuleCatalog.ts b/src/renderer/src/modules/useModuleCatalog.ts new file mode 100644 index 0000000..53e70c3 --- /dev/null +++ b/src/renderer/src/modules/useModuleCatalog.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { type CatalogModule, parseIndex } from './catalog'; + +export interface ModuleCatalog { + byName: Map; + error?: string; + // When the list was downloaded; offline, the cached copy's date. + fetchedAt: string | null; + loading: boolean; + modules: CatalogModule[]; + offline: boolean; + refresh: () => void; +} + +/** The module index, loaded once `enabled` (the Modules view first opens), kept for the session. */ +export function useModuleCatalog(enabled: boolean): ModuleCatalog { + const [state, setState] = useState>({ fetchedAt: null, loading: false, modules: [], offline: false }); + const requested = useRef(false); + + const load = useCallback((refresh: boolean) => { + setState(current => ({ ...current, loading: true })); + const api = window.api; + if (!api) { + setState(current => ({ ...current, loading: false, offline: true, error: 'No connection to the app' })); + return; + } + api + .getModuleIndex(refresh) + .then(result => + setState({ + error: result.error, + fetchedAt: result.fetchedAt, + loading: false, + modules: result.index ? parseIndex(result.index) : [], + offline: result.offline + }) + ) + .catch((error: unknown) => + setState(current => ({ ...current, loading: false, offline: true, error: error instanceof Error ? error.message : String(error) })) + ); + }, []); + + useEffect(() => { + if (!enabled || requested.current) return; + requested.current = true; + load(false); + }, [enabled, load]); + + const byName = useMemo(() => new Map(state.modules.map(module => [module.name, module])), [state.modules]); + const refresh = useCallback(() => load(true), [load]); + return { ...state, byName, refresh }; +} diff --git a/src/renderer/src/modules/useModuleDetails.ts b/src/renderer/src/modules/useModuleDetails.ts new file mode 100644 index 0000000..e7c7d6a --- /dev/null +++ b/src/renderer/src/modules/useModuleDetails.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { type CatalogModule, sourceUrl } from './catalog'; +import { type ModuleWebDetails, parseDetails } from './moduleDetails'; + +export interface ModuleDetailsState { + details: ModuleWebDetails | null; + error?: string; + loading: boolean; + offline: boolean; +} + +// Where a README's relative links point: the module's folder in its repository, at its commit. +function readmeBase(module: CatalogModule, commit?: string) { + if (!module.repo?.startsWith('https://')) return undefined; + if (!commit) return `${sourceUrl(module)}/`; + return `${module.repo.replace(/\/$/, '')}/blob/${commit}/${module.subdirectory ? `${module.subdirectory}/` : ''}`; +} + +/** The website's details of a module (README, author, downloads, versions), loaded when its page opens. */ +export function useModuleDetails(module: CatalogModule | undefined): ModuleDetailsState { + const name = module?.name; + const [state, setState] = useState({ details: null, loading: false, offline: false }); + + useEffect(() => { + if (!name || !module) return; + let current = true; + const api = window.api; + if (!api) return; + api + .getModuleDetails(name) + .then(result => { + if (!current) return; + const commit = typeof result.details?.commit === 'string' ? result.details.commit : undefined; + setState({ + name, + details: result.details ? parseDetails(result.details, readmeBase(module, commit)) : null, + error: result.error, + loading: false, + offline: result.offline + }); + }) + .catch( + (error: unknown) => + current && setState({ name, details: null, error: error instanceof Error ? error.message : String(error), loading: false, offline: true }) + ); + return () => { + current = false; + }; + }, [name, module]); + + // Until this module's answer arrives, it's loading (not the previous module's details). + return state.name === name ? state : { details: null, loading: Boolean(name), offline: false }; +} diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index 08bbca0..9b764e1 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -19,6 +19,7 @@ import { describeFileCondition } from '../canvas/fileCondition'; import { GATE_EDGE_PREFIX, type Gate, deriveGates, gateKey } from '../canvas/gates'; import { GRID_SIZE, NODE_WIDTH, type Position, estimateNodeHeight, nextStackPosition } from '../canvas/layout'; import { tidyPositions } from '../canvas/tidy'; +import { ActivityBar } from '../components/ActivityBar'; import { BlockGroupRow } from '../components/BlockGroupRow'; import { BlockPalette } from '../components/BlockPalette'; import { DeploymentView } from '../components/DeploymentView'; @@ -35,6 +36,7 @@ import { TestResultsView } from '../components/TestResultsView'; import { PROJECT_TABS, TopBar } from '../components/TopBar'; import { AgentLock } from '../components/agent/AgentLock'; import { ConfirmDialog } from '../components/dialogs/ConfirmDialog'; +import { ModulePageOr } from '../components/modules/ModulePage'; import { ConditionSection } from '../components/properties/ConditionSection'; import { RunsWhenSection } from '../components/properties/RunsWhenSection'; import { buildClassNameOptions } from '../components/properties/classOptions'; @@ -127,12 +129,15 @@ import { } from '../store/groupsSlice'; import { historyBatchEnded, historyBatchStarted, inOneStep, redone, undone } from '../store/history'; import { selectCurrentProject } from '../store/projectSlice/selectors'; +import { SideBar } from './SideBar'; import { routeProjectCommand, useProjectMenuCommands } from './menuCommands'; import { definedNames } from './pasteCopies'; import { useClipboardActions } from './useClipboardActions'; import { useGroupActions } from './useGroupActions'; import { isEditableTarget, modalOpen, useKeyboardShortcuts } from './useKeyboardShortcuts'; +import { useProjectModules } from './useProjectModules'; import { useSelection } from './useSelection'; +import { useSideBar } from './useSideBar'; const UNDO_KEY = navigator.platform.startsWith('Mac') ? '⌘Z' : 'Ctrl+Z'; const GROUP_KEY = navigator.platform.startsWith('Mac') ? '⌘G' : 'Ctrl+G'; @@ -350,8 +355,13 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti const canRedo = useAppSelector(state => state.history.future.length > 0); // In-app "full screen": side panels hidden, properties as an overlay. const [maximized, setMaximized] = useState(false); - // Test Results and Deployment have their own layout: no block palette or Properties panel. - const showSidebars = !maximized && activeTab < 2; + const sideBar = useSideBar({ activeTab, maximized, setActiveTab, setMaximized }); + const modules = useProjectModules(sideBar.activeView === 'modules', PROJECT_TABS[activeTab]); + // Another tab, or the canvas's file or a block: the module page closes. + const showTab = (tab: number) => { + modules.close(); + setActiveTab(tab); + }; const testActivity = useTestActivity(); // Looking at the Test Results tab acknowledges how the last action ended. useEffect(() => { @@ -797,7 +807,8 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti ungroup: ungroupSelection }, view: { - showTab: setActiveTab, + showSideView: sideBar.show, + showTab, tidy: () => setTidyConfirmOpen(true), toggleMaximized: () => setMaximized(current => !current), zoom: control => zoomControlsRef.current?.[control]() @@ -828,7 +839,11 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti onDragCancel: useStableCallback(() => setDragPreview(null)) }; // The sidebars are memoized: they get stable callbacks, and counts that change only with them. - const paletteAddBlock = useStableCallback((descriptor: BlockDescriptor) => addBlockToCanvas(descriptor)); + const paletteAddBlock = useStableCallback((descriptor: BlockDescriptor) => { + sideBar.toCanvas(); + modules.close(); + addBlockToCanvas(descriptor); + }); const blockCountsJson = useMemo(() => { const counts: Record = {}; for (const instance of allInstances) counts[instance.fileId] = (counts[instance.fileId] ?? 0) + 1; @@ -839,6 +854,8 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti onSelectFile: useStableCallback((fileId: string) => { dispatch(fileSelected({ fileId })); setSelectedInstanceId(null); + sideBar.toCanvas(); + modules.close(); }), onAddFile: useStableCallback(handleAddFile), onAddFolder: useStableCallback(handleAddFolder), @@ -1035,7 +1052,7 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti fileGate={describeFileCondition(currentFile?.condition)} blockCount={instances.length} activeTab={activeTab} - onTabChange={setActiveTab} + onTabChange={showTab} tabBadges={{ 2: , 3: }} onSave={onSave} onOpenSettings={onOpenSettings} @@ -1047,17 +1064,15 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti {locked && } - {showSidebars && ( - <> - + + @@ -1078,141 +1093,143 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti onMove={explorer.onMove} /> - - - - )} + + } + /> - {activeTab === 0 ? ( - { - setSelectedGateKey(key); - if (key) { - setMultiSelectedIds([]); - setSelectedInstanceId(null); - setSelectedEdgeId(null); - } - }} - onDerivedMove={(key, position) => dispatch(derivedNodeMoved({ key, position }))} - dataCallbacks={{ - open: owner => { - setMultiSelectedIds([]); - setSelectedInstanceId(owner.instanceId); - setFocusEntryId(owner.entryId ?? null); + + {activeTab === 0 ? ( + { + setSelectedGateKey(key); + if (key) { + setMultiSelectedIds([]); + setSelectedInstanceId(null); + setSelectedEdgeId(null); + } + }} + onDerivedMove={(key, position) => dispatch(derivedNodeMoved({ key, position }))} + dataCallbacks={{ + open: owner => { + setMultiSelectedIds([]); + setSelectedInstanceId(owner.instanceId); + setFocusEntryId(owner.entryId ?? null); + setSelectedEdgeId(null); + setSelectedGateKey(null); + }, + remove: removeChain, + addStep: (owner, decoratorId) => dispatch(decoratorAdded(owner.instanceId, decoratorId, owner.entryId)), + moveStep: (owner, fromIndex, toIndex) => + dispatch(decoratorMoved({ instanceId: owner.instanceId, entryId: owner.entryId, fromIndex, toIndex })), + removeStep: (owner, decoratorInstanceId) => + dispatch(decoratorRemoved({ instanceId: owner.instanceId, entryId: owner.entryId, decoratorInstanceId })), + sampleChange: (owner, value) => dispatch(sampleInputChanged({ instanceId: owner.instanceId, entryId: owner.entryId, value })) + }} + onGateLink={(gate, instanceId) => { + const target = instances.find(instance => instance.instanceId === instanceId); + if (target?.condition?.className) announce(`Replaced "${target.label}"'s previous condition`); + dispatch(conditionSet({ instanceId, condition: gate.condition })); + }} + onGateLinkRemove={instanceId => { + dispatch(conditionRemoved({ instanceId })); setSelectedEdgeId(null); - setSelectedGateKey(null); - }, - remove: removeChain, - addStep: (owner, decoratorId) => dispatch(decoratorAdded(owner.instanceId, decoratorId, owner.entryId)), - moveStep: (owner, fromIndex, toIndex) => dispatch(decoratorMoved({ instanceId: owner.instanceId, entryId: owner.entryId, fromIndex, toIndex })), - removeStep: (owner, decoratorInstanceId) => - dispatch(decoratorRemoved({ instanceId: owner.instanceId, entryId: owner.entryId, decoratorInstanceId })), - sampleChange: (owner, value) => dispatch(sampleInputChanged({ instanceId: owner.instanceId, entryId: owner.entryId, value })) - }} - onGateLink={(gate, instanceId) => { - const target = instances.find(instance => instance.instanceId === instanceId); - if (target?.condition?.className) announce(`Replaced "${target.label}"'s previous condition`); - dispatch(conditionSet({ instanceId, condition: gate.condition })); - }} - onGateLinkRemove={instanceId => { - dispatch(conditionRemoved({ instanceId })); - setSelectedEdgeId(null); - }} - onGateModeChange={(gate, mode) => changeGateMode(gate, mode)} - onGateRemove={gate => removeGateByKey(gate.key)} - onFileConditionRemove={() => currentFile && dispatch(fileConditionRemoved({ fileId: currentFile.id }))} - duplicateKeys={duplicateKeys} - cutPendingId={cutPendingId} - measured={measured} - maximized={maximized} - selectedInstanceId={selectedInstanceId} - selectedChainEntryId={focusEntryId} - multiSelectedIds={multiSelectedIds} - onMultiSelect={handleMultiSelect} - groups={groups} - selectedGroupId={selectedGroupId} - onSelectGroup={handleSelectGroup} - {...groupCanvasCallbacks} - selectedEdgeId={selectedEdgeId} - onInit={instance => { - flowRef.current = instance; - }} - onSelectInstance={instanceId => (instanceId ? selectOnly(instanceId) : clearSelection())} - onSelectEdge={edgeId => { - setSelectedEdgeId(edgeId); - if (edgeId) { - setMultiSelectedIds([]); - setSelectedInstanceId(null); - setSelectedGateKey(null); + }} + onGateModeChange={(gate, mode) => changeGateMode(gate, mode)} + onGateRemove={gate => removeGateByKey(gate.key)} + onFileConditionRemove={() => currentFile && dispatch(fileConditionRemoved({ fileId: currentFile.id }))} + duplicateKeys={duplicateKeys} + cutPendingId={cutPendingId} + measured={measured} + maximized={maximized} + selectedInstanceId={selectedInstanceId} + selectedChainEntryId={focusEntryId} + multiSelectedIds={multiSelectedIds} + onMultiSelect={handleMultiSelect} + groups={groups} + selectedGroupId={selectedGroupId} + onSelectGroup={handleSelectGroup} + {...groupCanvasCallbacks} + selectedEdgeId={selectedEdgeId} + onInit={instance => { + flowRef.current = instance; + }} + onSelectInstance={instanceId => (instanceId ? selectOnly(instanceId) : clearSelection())} + onSelectEdge={edgeId => { + setSelectedEdgeId(edgeId); + if (edgeId) { + setMultiSelectedIds([]); + setSelectedInstanceId(null); + setSelectedGateKey(null); + } + }} + onRemove={handleRemove} + onMove={(instanceId, position) => dispatch(blockMoved({ instanceId, position }))} + onNodeDragStart={() => { + dragBatchRef.current = true; + dispatch(historyBatchStarted()); + }} + onNodeDragStop={() => { + dragBatchRef.current = false; + dispatch(historyBatchEnded()); + }} + onMeasured={(sizes, compact) => { + setMeasured(current => ({ ...current, ...sizes })); + if (!compact) setLayoutSizes(current => ({ ...current, ...sizes })); + }} + onConnect={(source, target, outcomes) => currentFileId && dispatch(edgeAdded({ fileId: currentFileId, source, target, outcomes }))} + onEdgeOutcomesChange={(edgeId, outcomes) => dispatch(edgeOutcomesChanged({ edgeId, outcomes }))} + onEdgeRemove={edgeId => { + dispatch(edgeRemoved({ edgeId })); + if (selectedEdgeId === edgeId) setSelectedEdgeId(null); + }} + onInvalidConnection={announce} + onTidy={() => setTidyConfirmOpen(true)} + onToggleMaximize={() => setMaximized(current => !current)} + zoomControlsRef={zoomControlsRef} + toolbarExtra={ + maximized && ( + + ) } - }} - onRemove={handleRemove} - onMove={(instanceId, position) => dispatch(blockMoved({ instanceId, position }))} - onNodeDragStart={() => { - dragBatchRef.current = true; - dispatch(historyBatchStarted()); - }} - onNodeDragStop={() => { - dragBatchRef.current = false; - dispatch(historyBatchEnded()); - }} - onMeasured={(sizes, compact) => { - setMeasured(current => ({ ...current, ...sizes })); - if (!compact) setLayoutSizes(current => ({ ...current, ...sizes })); - }} - onConnect={(source, target, outcomes) => currentFileId && dispatch(edgeAdded({ fileId: currentFileId, source, target, outcomes }))} - onEdgeOutcomesChange={(edgeId, outcomes) => dispatch(edgeOutcomesChanged({ edgeId, outcomes }))} - onEdgeRemove={edgeId => { - dispatch(edgeRemoved({ edgeId })); - if (selectedEdgeId === edgeId) setSelectedEdgeId(null); - }} - onInvalidConnection={announce} - onTidy={() => setTidyConfirmOpen(true)} - onToggleMaximize={() => setMaximized(current => !current)} - zoomControlsRef={zoomControlsRef} - toolbarExtra={ - maximized && ( - - ) - } - /> - ) : ( - setActiveTab(2)} - compiled={compiled} - currentFileId={currentFileId} - selectedId={selectedGroupId ?? selectedInstanceId} - onSelect={id => selectInFile(id)} - onFollow={(fileId, id) => { - dispatch(fileSelected({ fileId })); - selectInFile(id); - }} - onShowBlock={(fileId, id) => { - // A problem's block (or group) on the canvas, selected. - dispatch(fileSelected({ fileId })); - setActiveTab(0); - selectInFile(id); - }} - /> - )} + /> + ) : ( + setActiveTab(2)} + compiled={compiled} + currentFileId={currentFileId} + selectedId={selectedGroupId ?? selectedInstanceId} + onSelect={id => selectInFile(id)} + onFollow={(fileId, id) => { + dispatch(fileSelected({ fileId })); + selectInFile(id); + }} + onShowBlock={(fileId, id) => { + // A problem's block (or group) on the canvas, selected. + dispatch(fileSelected({ fileId })); + setActiveTab(0); + selectInFile(id); + }} + /> + )} + - {showSidebars && ( + {sideBar.showProperties(modules.page !== undefined) && ( <> ; + onResize: (deltaPx: number) => void; + onResizeEnd: () => void; + // The Blocks palette above the Files explorer. + policy: ReactNode; + view: SideView | null; + widthFraction: number; +} + +/** The side bar right of the activity bar: the open view, and its resize handle. */ +export function SideBar({ modules, onResize, onResizeEnd, policy, view, widthFraction }: SideBarProps) { + if (!view) return null; + return ( + <> + + {view === 'modules' ? ( + + ) : ( + policy + )} + + + + ); +} diff --git a/src/renderer/src/pages/menuCommands.test.ts b/src/renderer/src/pages/menuCommands.test.ts index bfce96a..f012566 100644 --- a/src/renderer/src/pages/menuCommands.test.ts +++ b/src/renderer/src/pages/menuCommands.test.ts @@ -18,7 +18,7 @@ function context(overrides: Partial undo: vi.fn(), ungroup: vi.fn() }, - view: { showTab: vi.fn(), tidy: vi.fn(), toggleMaximized: vi.fn(), zoom: vi.fn() }, + view: { showSideView: vi.fn(), showTab: vi.fn(), tidy: vi.fn(), toggleMaximized: vi.fn(), zoom: vi.fn() }, ...overrides } satisfies ProjectCommandContext; } diff --git a/src/renderer/src/pages/menuCommands.ts b/src/renderer/src/pages/menuCommands.ts index 68e71f6..e107cf9 100644 --- a/src/renderer/src/pages/menuCommands.ts +++ b/src/renderer/src/pages/menuCommands.ts @@ -45,15 +45,25 @@ export interface ProjectCommandContext { ungroup: () => void; }; textFocused: boolean; - view: { showTab: (index: number) => void; tidy: () => void; toggleMaximized: () => void; zoom: (control: keyof ZoomControls) => void }; + view: { + // A side bar view, or null to collapse / reopen the side bar. + showSideView: (view: 'modules' | 'policy' | null) => void; + showTab: (index: number) => void; + tidy: () => void; + toggleMaximized: () => void; + zoom: (control: keyof ZoomControls) => void; + }; } +const SIDE_VIEW_COMMANDS: Record = { 'sidebar-toggle': null, 'view-modules': 'modules', 'view-policy': 'policy' }; + const ZOOM_COMMANDS: Record = { zoomIn: 'zoomIn', zoomOut: 'zoomOut', zoomReset: 'reset', zoomFit: 'fit' }; // The View menu: tabs anywhere; the canvas's own commands do nothing on the other tabs. function routeViewCommand(command: string, { canvasShowing, view }: ProjectCommandContext): boolean { const tab = /^tab-([0-3])$/.exec(command); if (tab) view.showTab(Number(tab[1])); + else if (command in SIDE_VIEW_COMMANDS) view.showSideView(SIDE_VIEW_COMMANDS[command]); else if (command !== 'tidy' && command !== 'maximize' && !(command in ZOOM_COMMANDS)) return false; else if (!canvasShowing) return true; else if (command === 'tidy') view.tidy(); diff --git a/src/renderer/src/pages/useProjectModules.ts b/src/renderer/src/pages/useProjectModules.ts new file mode 100644 index 0000000..1f07333 --- /dev/null +++ b/src/renderer/src/pages/useProjectModules.ts @@ -0,0 +1,58 @@ +import { useState } from 'react'; + +import { modulesToAdd, modulesToRemove } from '../modules/catalog'; +import { useModuleCatalog } from '../modules/useModuleCatalog'; +import { useModuleDetails } from '../modules/useModuleDetails'; +import { useAppDispatch, useAppSelector } from '../store'; +import { modulesAdded, modulesRemoved } from '../store/modulesSlice'; + +/** + * The project's CFEngine Build modules, and the one whose page is open over the center area. + * `backLabel`: what closing the page goes back to (the tab's name). + */ +export function useProjectModules(viewOpen: boolean, backLabel: string) { + const dispatch = useAppDispatch(); + const projectModules = useAppSelector(state => state.modules); + const [openName, setOpenName] = useState(null); + const catalog = useModuleCatalog(viewOpen || openName !== null); + const open = openName ? catalog.byName.get(openName) : undefined; + const web = useModuleDetails(open); + + // Input isn't stored yet: a module that asks for some needs it. + const isNeedingInput = (name: string) => (catalog.byName.get(name)?.input.length ?? 0) > 0; + const add = (name: string) => dispatch(modulesAdded(modulesToAdd(catalog.byName, name, projectModules))); + const remove = (name: string) => dispatch(modulesRemoved(modulesToRemove(catalog.byName, name, projectModules))); + const close = () => setOpenName(null); + + const index = openName ? projectModules.findIndex(module => module.name === openName) : -1; + const page = open && { + module: open, + web, + backLabel, + added: index >= 0 ? { index, module: projectModules[index], total: projectModules.length } : undefined, + needsInput: isNeedingInput(open.name), + wouldAdd: + index >= 0 + ? [] + : modulesToAdd(catalog.byName, open.name, projectModules) + .slice(1) + .map(module => module.name), + onAdd: () => add(open.name), + onRemove: () => remove(open.name), + onBack: close, + onOpenModule: setOpenName + }; + + return { + add, + catalog, + close, + isNeedingInput, + needingInput: projectModules.filter(module => isNeedingInput(module.name)).length, + open: setOpenName, + openName, + page, + projectModules, + remove + }; +} diff --git a/src/renderer/src/pages/useSideBar.ts b/src/renderer/src/pages/useSideBar.ts new file mode 100644 index 0000000..74c3a9f --- /dev/null +++ b/src/renderer/src/pages/useSideBar.ts @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +import type { SideView } from '../components/ActivityBar'; + +interface SideBarOptions { + activeTab: number; + maximized: boolean; + setActiveTab: (tab: number) => void; + setMaximized: (maximized: boolean) => void; +} + +/** + * The side bar's view and whether it's open, as VS Code's activity bar: clicking the open view + * collapses it. It starts open on the canvas tabs and collapsed on Tests and Deployment, which + * need the width; each remembers its own. + */ +export function useSideBar({ activeTab, maximized, setActiveTab, setMaximized }: SideBarOptions) { + const [view, setView] = useState('policy'); + const [openOnCanvas, setOpenOnCanvas] = useState(true); + const [openElsewhere, setOpenElsewhere] = useState(false); + const onCanvasTabs = activeTab < 2; + const open = !maximized && (onCanvasTabs ? openOnCanvas : openElsewhere); + const setOpen = onCanvasTabs ? setOpenOnCanvas : setOpenElsewhere; + + // A view, or null to collapse / reopen the one last open. + const show = (next: SideView | null) => { + if (maximized) { + setMaximized(false); + if (next) setView(next); + setOpen(true); + return; + } + if (next === null || (open && next === view)) { + setOpen(!open); + return; + } + setView(next); + setOpen(true); + }; + + return { + activeView: open ? view : null, + show, + // Properties: on the canvas tabs, unless a module's page covers them. + showProperties: (pageOpen: boolean) => !maximized && onCanvasTabs && !pageOpen, + // Opening a file or adding a block from Tests or Deployment goes to the canvas. + toCanvas: () => { + if (!onCanvasTabs) setActiveTab(0); + } + }; +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 8f94be4..6186717 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -9,6 +9,7 @@ import edgesReducer from './edgesSlice'; import filesReducer from './filesSlice'; import groupsReducer from './groupsSlice'; import { withHistory } from './history'; +import modulesReducer from './modulesSlice'; import projectReducer, { projectClosed } from './projectSlice'; import testEnvironmentsReducer from './testEnvironmentsSlice'; @@ -20,6 +21,7 @@ const appReducer = withHistory( edges: edgesReducer, files: filesReducer, groups: groupsReducer, + modules: modulesReducer, project: projectReducer, testEnvironments: testEnvironmentsReducer }) diff --git a/src/renderer/src/store/modulesSlice/index.ts b/src/renderer/src/store/modulesSlice/index.ts new file mode 100644 index 0000000..d8be08f --- /dev/null +++ b/src/renderer/src/store/modulesSlice/index.ts @@ -0,0 +1,29 @@ +import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; + +import { projectCreated, projectLoaded } from '../projectSlice'; + +/** A CFEngine Build module in the project, in build order; a dependency names the module it came with. */ +export interface ProjectModule { + addedBy?: string; + name: string; + version?: string; +} + +const modulesSlice = createSlice({ + name: 'modules', + initialState: [] as ProjectModule[], + reducers: { + // A module and the dependencies it brings, at the end of the build. + modulesAdded: (state, action: PayloadAction) => { + const present = new Set(state.map(module => module.name)); + state.push(...action.payload.filter(module => !present.has(module.name))); + }, + modulesRemoved: (state, action: PayloadAction) => state.filter(module => !action.payload.includes(module.name)) + }, + extraReducers: builder => { + builder.addCase(projectCreated, () => []).addCase(projectLoaded, () => []); + } +}); + +export const { modulesAdded, modulesRemoved } = modulesSlice.actions; +export default modulesSlice.reducer; From 55baa4e95673f2f857e828eabb63a4b9cf8579e9 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 16:17:52 +0300 Subject: [PATCH 02/23] Added modules are saved with cfbs, and their input answered on the module page Saving runs cfbs add / remove for the modules gained or dropped since the last save, before writing anything else, and writes each module's answers to /input.json. Opening a project reads them back. Adding, removing and answering are undoable project edits. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 53 ++++++++ python/tests/test_cfpb_backend.py | 10 ++ src/main/backend.ts | 7 ++ src/main/project.ts | 118 +++++++++++++++++- src/preload/api.d.ts | 4 + .../src/components/DeploymentView.tsx | 10 +- .../src/components/TestResultsView.tsx | 10 +- .../components/modules/ModuleInputForm.tsx | 114 +++++++++++++++++ .../src/components/modules/ModulePage.tsx | 56 ++++++--- src/renderer/src/mcp/testTools.ts | 10 +- src/renderer/src/modules/catalog.ts | 39 +++++- src/renderer/src/modules/moduleInput.test.ts | 44 +++++++ src/renderer/src/modules/moduleInput.ts | 29 +++++ src/renderer/src/pages/SideBar.tsx | 2 +- src/renderer/src/pages/useProjectModules.ts | 22 +++- src/renderer/src/project/cfbsProject.ts | 49 +++++++- .../src/project/projectChanges.test.ts | 1 + src/renderer/src/project/useCompiledPolicy.ts | 2 +- src/renderer/src/project/useProjectSession.ts | 3 +- src/renderer/src/store/history.ts | 5 +- src/renderer/src/store/modulesSlice/index.ts | 15 ++- src/renderer/src/store/projectSlice/types.ts | 2 + 22 files changed, 558 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/components/modules/ModuleInputForm.tsx create mode 100644 src/renderer/src/modules/moduleInput.test.ts create mode 100644 src/renderer/src/modules/moduleInput.ts diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index d53f507..8e7ce91 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -6,6 +6,7 @@ cfpb-backend compile builder project on stdin -> {"files": {path: policy}} on stdout cfpb-backend masterfiles {"version"} on stdin -> the masterfiles build entry on stdout cfpb-backend build {"path"} on stdin -> the built policy set and its checks on stdout + cfpb-backend modules {"directory", "add", "remove"} on stdin -> cfbs add / remove there Calls cfengine_cli and cfbs in-process. Import cfengine_cli.format, never cfengine_cli.main — that one pulls in cf_remote and ~27 MB of libcloud. @@ -306,6 +307,56 @@ def init_command() -> int: return 0 +# A module name in the CFEngine Build index (cfbs takes these as they are). +INDEX_MODULE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,100}$", re.I) + + +def _run_cfbs(directory: str, argv: list[str], what: str) -> None: + """cfbs in the project folder; a failure raises with its last error line.""" + import cfbs.main + from cfbs.cfbs_config import CFBSConfig + + CFBSConfig.instance = None # process-wide singleton: each run reads cfbs.json afresh + with _cfbs_session(directory, ["--non-interactive", *argv]) as tee: + try: + code = cfbs.main.main() + except SystemExit as exit: + code = exit.code if isinstance(exit.code, int) else 1 + if code == 0: + return + output = "".join(tee.chunks) + if any(hint in output.lower() for hint in NETWORK_HINTS): + raise InitFailed(f"Couldn't download {what} — check your network connection.") + errors = [line[len("Error: ") :] for line in output.splitlines() if line.startswith("Error: ")] + raise InitFailed(f"cfbs {argv[0]} failed: {errors[-1]}" if errors else f"cfbs {argv[0]} failed (exit {code})") + + +def modules_command() -> int: + """Adds and removes CFEngine Build modules in a project's cfbs.json (with their dependencies, + as cfbs does: added before the module, removed once nothing needs them).""" + try: + request = json.loads(sys.stdin.read()) + directory, add, remove = request["directory"], request.get("add", []), request.get("remove", []) + names = [*add, *remove] + if not os.path.isfile(os.path.join(directory, "cfbs.json")) or not all( + isinstance(name, str) and INDEX_MODULE.match(name) for name in names + ): + raise ValueError + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + print('Expected {"directory": , "add": [names], "remove": [names]} on stdin', file=sys.stderr) + return 2 + try: + if remove: + _run_cfbs(directory, ["remove", *remove], ", ".join(remove)) + if add: + _run_cfbs(directory, ["add", *add], ", ".join(add)) + except InitFailed as error: + print(str(error), file=sys.stderr) + return 1 + print(json.dumps({"added": add, "removed": remove})) + return 0 + + def masterfiles_command() -> int: """The masterfiles build entry cfbs init writes for a version, from a throwaway project: for turning a module into a policy set.""" @@ -523,6 +574,7 @@ def main(argv: list[str] | None = None) -> int: commands.add_parser("lint-custom", help="check Custom CFEngine blocks' text") commands.add_parser("build", help="build a saved project's policy set and check it") commands.add_parser("deploy", help="deploy a built policy set to a hub with cf-remote") + commands.add_parser("modules", help="add / remove CFEngine Build modules in a project") testenv = commands.add_parser("testenv", help="test environments (Docker hosts)") testenv.add_argument( "action", @@ -556,6 +608,7 @@ def main(argv: list[str] | None = None) -> int: "masterfiles": masterfiles_command, "build": build_command, "deploy": deploy_command, + "modules": modules_command, } return commands.get(args.command, format_command)() diff --git a/python/tests/test_cfpb_backend.py b/python/tests/test_cfpb_backend.py index 2cd4326..252a261 100644 --- a/python/tests/test_cfpb_backend.py +++ b/python/tests/test_cfpb_backend.py @@ -458,3 +458,13 @@ def test_sigterm_runs_the_cleanup_before_exiting(): stdout, _ = process.communicate(timeout=10) assert stdout == "cleaned up\n" assert process.returncode == 143 + + +def test_modules_takes_a_project_and_module_names(tmp_path, monkeypatch: pytest.MonkeyPatch): + # Nothing runs for a folder without cfbs.json, or a name cfbs wouldn't take. + request = lambda **parts: json.dumps({"directory": str(tmp_path), **parts}) + assert _run(["modules"], request(add=["delete-files"]), monkeypatch)[0] == 2 + (tmp_path / "cfbs.json").write_text("{}") + assert _run(["modules"], request(add=["../evil"]), monkeypatch)[0] == 2 + code, stdout, _ = _run(["modules"], request(add=[], remove=[]), monkeypatch) + assert code == 0 and json.loads(stdout) == {"added": [], "removed": []} diff --git a/src/main/backend.ts b/src/main/backend.ts index 91d46a9..1ca569e 100644 --- a/src/main/backend.ts +++ b/src/main/backend.ts @@ -384,6 +384,13 @@ export function warmUpSidecar(): void { * The masterfiles build entry cfbs writes for `version` ("3.27.1" or "master"), for turning a * module into a policy set. Needs the network, like New Project. */ +/** Adds and removes CFEngine Build modules in a project's cfbs.json (cfbs add / remove; may download). */ +export async function syncModules(directory: string, add: string[], remove: string[]): Promise { + const result = await runSidecar(['modules'], JSON.stringify({ directory, add, remove }), INIT_TIMEOUT_MS); + logStderr(result.stderr); + if (result.code !== 0) throw sidecarError(result, INIT_TIMEOUT_MS); +} + export async function masterfilesEntry(version: string): Promise> { const result = await runSidecar(['masterfiles'], JSON.stringify({ version }), INIT_TIMEOUT_MS); logStderr(result.stderr); diff --git a/src/main/project.ts b/src/main/project.ts index 847c97b..1099d5e 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -13,7 +13,7 @@ import type { RecentProject, TargetCheck } from '../preload/api'; -import { compilePolicy, initCfbsProject, masterfilesEntry } from './backend'; +import { compilePolicy, initCfbsProject, masterfilesEntry, syncModules } from './backend'; /** * The project:* IPC channels: creating a cfbs project on disk (via the Python @@ -172,6 +172,22 @@ export function checkedBuilderModules(modules: unknown[], provided: Record + isRecord(module) && + typeof module.name === 'string' && + INDEX_MODULE.test(module.name) && + (module.addedBy === undefined || (typeof module.addedBy === 'string' && INDEX_MODULE.test(module.addedBy))) && + (module.input === undefined || isRecord(module.input)); + +/** The CFEngine Build modules in a `build`: index entries carry a commit (the builder's own don't); not masterfiles. */ +const indexModuleEntries = (build: unknown[]) => + build.filter( + (entry): entry is Record & { name: string } => + isRecord(entry) && typeof entry.name === 'string' && entry.name !== 'masterfiles' && typeof entry.commit === 'string' + ); + export function checkedContent(value: unknown): ProjectContent { const content = value as Partial | null; if (!isRecord(content) || !isRecord(content.project) || !Array.isArray(content.modules) || !isRecord(content.provided)) { @@ -186,6 +202,9 @@ export function checkedContent(value: unknown): ProjectContent { const names = content.modules.map(module => (isRecord(module) ? module.name : undefined)); if (names.length !== modules.size || !names.every(name => typeof name === 'string' && modules.has(name))) throw new InvalidRequest('Invalid policy module'); checkedBuilderModules(content.modules, content.provided); + if (content.buildModules !== undefined && !(Array.isArray(content.buildModules) && content.buildModules.every(isBuildModule))) { + throw new InvalidRequest('Invalid modules'); + } const environments = content.testEnvironments; if (environments !== undefined && !(Array.isArray(environments) && environments.every(isRecord) && environments.every(hasValidEnvFile))) { throw new InvalidRequest('Invalid test environments'); @@ -378,14 +397,89 @@ async function checkInProject(projectPath: string, path: string): Promise if (real === null || (real !== root && !isInside(real, root))) throw new Error(`Refusing to save through a link out of the project: ${path}`); } +/** + * Adds the modules the project gained since the last save and removes the ones it lost (only those + * added on their own: cfbs removes dependencies nothing needs any more). Returns the removed ones. + */ +async function syncBuildModules( + projectPath: string, + type: ProjectType, + cfbs: Record, + wanted: ProjectContent['buildModules'] +): Promise { + // Modules go into policy sets only. + if (!wanted || type !== 'policy-set') return []; + const entries = indexModuleEntries(Array.isArray(cfbs.build) ? cfbs.build : []); + const have = new Set(entries.map(entry => entry.name)); + const names = new Set(wanted.map(module => module.name)); + const add = wanted.filter(module => !module.addedBy && !have.has(module.name)).map(module => module.name); + const remove = entries.filter(entry => entry.added_by === 'cfbs add' && !names.has(entry.name)).map(entry => entry.name); + if (add.length || remove.length) await syncModules(projectPath, add, remove); + return entries.filter(entry => !names.has(entry.name)).map(entry => entry.name); +} + +/** + * Each module's input answers as cfbs reads them: /input.json, its questions (from its build + * entry) each with a `response`. A removed module's file goes. + */ +async function writeModuleInputs( + projectPath: string, + type: ProjectType, + cfbs: Record, + modules: ProjectContent['buildModules'], + removed: string[] +): Promise { + if (!modules || type !== 'policy-set') return; + const entries = new Map(indexModuleEntries(Array.isArray(cfbs.build) ? cfbs.build : []).map(entry => [entry.name, entry])); + for (const { input, name } of modules) { + const questions = entries.get(name)?.input; + if (!input || !Array.isArray(questions)) continue; + const answered = questions.map(question => + isRecord(question) && typeof question.variable === 'string' && question.variable in input ? { ...question, response: input[question.variable] } : question + ); + const path = `./${name}/input.json`; + await checkInProject(projectPath, path); + await fs.mkdir(join(projectPath, name), { recursive: true }); + await writeFileAtomic(join(projectPath, path), `${JSON.stringify(answered, null, 2)}\n`); + } + for (const name of removed) { + const path = `./${name}/input.json`; + await checkInProject(projectPath, path); + await fs.rm(join(projectPath, path), { force: true }); + await removeEmptyFolders(projectPath, `./${name}`); + } +} + +/** Each CFEngine Build module's /input.json, parsed, for the ones that have one. */ +async function readModuleInputs(folder: string, cfbs: Record): Promise> { + const inputs: Record = {}; + for (const { name } of indexModuleEntries(Array.isArray(cfbs.build) ? cfbs.build : [])) { + if (!INDEX_MODULE.test(name)) continue; + const text = await fs.readFile(join(folder, name, 'input.json'), 'utf-8').catch(() => null); + if (text === null) continue; + try { + inputs[name] = JSON.parse(text); + } catch { + // Unreadable: the module shows as not answered. + } + } + return inputs; +} + /** * Saves the project: its generated files first, then .policy-builder/project.json, * then cfbs.json. Files the previous save generated and this one doesn't are removed. */ async function writeProjectContent(projectPath: string, content: ProjectContent, storage: ProjectStorage): Promise<{ masterfiles: string | null }> { const cfbsPath = join(projectPath, 'cfbs.json'); - const existing = JSON.parse(await fs.readFile(cfbsPath, 'utf-8')); - if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) throw new Error('cfbs.json is not a JSON object'); + const readCfbs = async () => { + const parsed: unknown = JSON.parse(await fs.readFile(cfbsPath, 'utf-8')); + if (!isRecord(parsed)) throw new Error('cfbs.json is not a JSON object'); + return parsed; + }; + // CFEngine Build modules first: cfbs add downloads them, so a failure leaves everything as it was. + const removedModules = await syncBuildModules(projectPath, storage.type, await readCfbs(), content.buildModules); + const existing = await readCfbs(); // A module becoming a policy set without masterfiles: fetch the entry before writing anything. const build: unknown[] = Array.isArray(existing.build) ? existing.build : []; const hasMasterfiles = build.some(entry => isRecord(entry) && entry.name === 'masterfiles'); @@ -421,6 +515,7 @@ async function writeProjectContent(projectPath: string, content: ProjectContent, await writeTestEnvironments(projectPath, content.testEnvironments); const merged = mergeCfbsJson(existing, saved, previous, storage.type, generated, masterfiles); await writeFileAtomic(cfbsPath, `${JSON.stringify(merged, null, 2)}\n`); + await writeModuleInputs(projectPath, storage.type, merged, content.buildModules, removedModules); const entry = (Array.isArray(merged.build) ? merged.build : []).find(item => isRecord(item) && item.name === 'masterfiles') as Record | undefined; const version = entry ? (typeof entry.version === 'string' ? entry.version : 'master') : null; @@ -557,9 +652,10 @@ async function openProject(event: IpcMainInvokeEvent, request: { path?: unknown // A module opens only if the builder made it: saving rewrites what it provides. if (cfbs.type === 'module' && !builder) throw new InvalidRequest('This cfbs.json is a cfbs module that wasn’t made with the Policy Builder.'); const testEnvironments = await readTestEnvironments(path); + const moduleInputs = await readModuleInputs(path, cfbs); knownProjects.add(path); await rememberRecent(path, typeof cfbs.name === 'string' && cfbs.name.trim() ? cfbs.name.trim() : basename(path)); - return { ok: true as const, builder, cfbs, path, testEnvironments }; + return { ok: true as const, builder, cfbs, moduleInputs, path, testEnvironments }; } async function createProject(request: CreateProjectRequest) { @@ -659,13 +755,25 @@ export function registerProjectHandlers(isTrustedFrame: (frame: WebFrameMain | n ipcMain.handle( 'project:save', trusted( - async (_event, request: { modules?: unknown; path?: unknown; project?: unknown; provided?: unknown; storage?: unknown; testEnvironments?: unknown }) => { + async ( + _event, + request: { + buildModules?: unknown; + modules?: unknown; + path?: unknown; + project?: unknown; + provided?: unknown; + storage?: unknown; + testEnvironments?: unknown; + } + ) => { try { const path = checkedAbsolutePath(request?.path, 'project path'); if (!knownProjects.has(path)) throw new InvalidRequest('Not a project opened in this session'); const content = checkedContent({ project: request.project, modules: request.modules, + buildModules: request.buildModules, provided: request.provided, testEnvironments: request.testEnvironments }); diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index c73647e..c1721b0 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -29,6 +29,8 @@ export type NativeEdit = 'copy' | 'cut' | 'delete' | 'paste' | 'redo' | 'selectA // What the builder saves: its cfbs.json modules (`build` entries for a policy set, the one // `provides` entry for a module), and its own data for .policy-builder/project.json. export interface ProjectContent { + // The CFEngine Build modules a policy set has (cfbs add / remove on save), with input answers. + buildModules?: { addedBy?: string; input?: Record; name: string }[]; modules: object[]; project: object; provided: object; @@ -290,6 +292,8 @@ export interface OpenedProject { builder: Record | null; // The parsed cfbs.json. cfbs: Record; + // Each CFEngine Build module's parsed /input.json, when it has one. + moduleInputs: Record; path: string; // The parsed .policy-builder/test-environments.json's environments, null without. testEnvironments: unknown[] | null; diff --git a/src/renderer/src/components/DeploymentView.tsx b/src/renderer/src/components/DeploymentView.tsx index ad6cf59..e58f8c3 100644 --- a/src/renderer/src/components/DeploymentView.tsx +++ b/src/renderer/src/components/DeploymentView.tsx @@ -35,7 +35,15 @@ import { ShipDialog, type ShipPlan, type ShipStep, planShip } from './deploy/shi // The project as saving writes it, without test environments: what a test run deployed. const snapshot = (): ProjectData => { const state = store.getState(); - return { canvas: state.canvas, derivedNodes: state.derivedNodes, edges: state.edges, files: state.files, groups: state.groups, testEnvironments: [] }; + return { + canvas: state.canvas, + derivedNodes: state.derivedNodes, + edges: state.edges, + files: state.files, + groups: state.groups, + modules: state.modules, + testEnvironments: [] + }; }; type GitResult = { details: string; message: string; ok: false } | { ok: true; status: GitStatus }; diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index 7b1be52..0fb400a 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -117,7 +117,15 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, const request = (hosts?: string[]) => { const state = store.getState(); const content = toCfbsProject( - { canvas: state.canvas, derivedNodes: state.derivedNodes, edges: state.edges, files: state.files, groups: state.groups, testEnvironments: [] }, + { + canvas: state.canvas, + derivedNodes: state.derivedNodes, + edges: state.edges, + files: state.files, + groups: state.groups, + modules: state.modules, + testEnvironments: [] + }, project! ); const masterfiles = project?.masterfiles ?? (environment!.version !== 'latest' ? environment!.version : versions.latest); diff --git a/src/renderer/src/components/modules/ModuleInputForm.tsx b/src/renderer/src/components/modules/ModuleInputForm.tsx new file mode 100644 index 0000000..c7835f1 --- /dev/null +++ b/src/renderer/src/components/modules/ModuleInputForm.tsx @@ -0,0 +1,114 @@ +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import { Box, Button, IconButton, TextField, Typography } from '@mui/material'; + +import type { ModuleInputField, ModuleInputQuestion } from '../../modules/catalog'; +import { isSupported, newEntry } from '../../modules/moduleInput'; +import type { ModuleInputResponse } from '../../store/modulesSlice'; + +interface ModuleInputFormProps { + answers: Record; + onChange: (variable: string, response: ModuleInputResponse) => void; + questions: ModuleInputQuestion[]; +} + +const fieldLabel = (field: ModuleInputField) => field.label ?? field.key ?? ''; + +function TextQuestion({ onChange, question, value }: { onChange: (value: string) => void; question: ModuleInputQuestion; value: string }) { + return ( + onChange(event.target.value)} + variant="standard" + size="small" + fullWidth + /> + ); +} + +function ListQuestion({ + onChange, + question, + value +}: { + onChange: (value: ModuleInputResponse) => void; + question: ModuleInputQuestion; + value: ModuleInputResponse | undefined; +}) { + const fields = Array.isArray(question.subtype) ? question.subtype : null; + const entries: (Record | string)[] = Array.isArray(value) ? value : []; + const update = (index: number, entry: Record | string) => + onChange(entries.map((current, at) => (at === index ? entry : current)) as ModuleInputResponse); + const single = !Array.isArray(question.subtype) ? question.subtype : undefined; + return ( + + {question.label ?? question.variable} + {question.question && {question.question}} + {entries.length === 0 && (Not set)} + {entries.map((entry, index) => ( + + {fields ? ( + fields.map(field => ( + update(index, { ...(typeof entry === 'string' ? {} : entry), [field.key ?? '']: event.target.value })} + variant="standard" + size="small" + sx={{ flex: 1 }} + /> + )) + ) : ( + update(index, event.target.value)} + variant="standard" + size="small" + sx={{ flex: 1 }} + /> + )} + onChange(entries.filter((_entry, at) => at !== index) as ModuleInputResponse)}> + + + + ))} + + + ); +} + +/** A module's input questions as a form, as Mission Portal's Build app shows them: texts, and lists of rows. */ +export function ModuleInputForm({ answers, onChange, questions }: ModuleInputFormProps) { + return ( + + {questions.map((question, index) => { + const variable = question.variable; + if (!variable || !isSupported(question)) + return ( + + {question.label ?? variable}: this input type isn’t supported in the builder yet; answer it with cfbs input. + + ); + return question.type === 'string' ? ( + onChange(variable, value)} + /> + ) : ( + onChange(variable, value)} /> + ); + })} + + ); +} diff --git a/src/renderer/src/components/modules/ModulePage.tsx b/src/renderer/src/components/modules/ModulePage.tsx index b64f7e6..6f1051b 100644 --- a/src/renderer/src/components/modules/ModulePage.tsx +++ b/src/renderer/src/components/modules/ModulePage.tsx @@ -6,7 +6,8 @@ import { Box, Button, CircularProgress, Link, Tab, Tabs, Typography } from '@mui import { type CatalogModule, sourceUrl } from '../../modules/catalog'; import type { ReadmeNode } from '../../modules/moduleDetails'; import type { ModuleDetailsState } from '../../modules/useModuleDetails'; -import type { ProjectModule } from '../../store/modulesSlice'; +import type { ModuleInputResponse, ProjectModule } from '../../store/modulesSlice'; +import { ModuleInputForm } from './ModuleInputForm'; import { KIND_LABELS, ModuleBadges } from './ModuleRow'; const readmeSx = { @@ -52,8 +53,11 @@ interface ModulePageProps { needsInput: boolean; onAdd: () => void; onBack: () => void; + onInputChange: (variable: string, response: ModuleInputResponse) => void; onOpenModule: (name: string) => void; onRemove: () => void; + // The Input tab first (Edit input). + openInput?: boolean; web: ModuleDetailsState; // The dependencies adding it would bring that the project doesn't have yet. wouldAdd: string[]; @@ -83,8 +87,21 @@ function Description({ web }: { web: ModuleDetailsState }) { } /** A module's page, as on build.cfengine.com: description, links, stats, versions and what it needs. */ -export function ModulePage({ added, backLabel, module, needsInput, onAdd, onBack, onOpenModule, onRemove, web, wouldAdd }: ModulePageProps) { - const [tab, setTab] = useState(0); +export function ModulePage({ + added, + backLabel, + module, + needsInput, + onAdd, + onBack, + onInputChange, + onOpenModule, + onRemove, + openInput, + web, + wouldAdd +}: ModulePageProps) { + const [tab, setTab] = useState(openInput && module.input.length > 0 ? 2 : 0); const details = web.details; const repo = module.repo?.startsWith('https://') ? module.repo.replace(/\/$/, '') : undefined; const source = sourceUrl(module); @@ -169,17 +186,23 @@ export function ModulePage({ added, backLabel, module, needsInput, onAdd, onBack ))} {tab === 2 && ( <> - {added ? 'Its questions, answered in the project:' : 'After adding, it asks:'} - {module.input.map((question, index) => ( - - {question.label ?? question.variable} - {question.question && ` — ${question.question}`} - - {' '} - ({question.type === 'list' ? 'a list' : 'text'}) - - - ))} + {added ? ( + + ) : ( + <> + Add the module to answer its questions: + {module.input.map((question, index) => ( + + {question.label ?? question.variable} + {question.question && ` — ${question.question}`} + + {' '} + ({question.type === 'list' ? 'a list' : 'text'}) + + + ))} + + )} )} @@ -212,7 +235,7 @@ export function ModulePage({ added, backLabel, module, needsInput, onAdd, onBack <> In this project - Runs after your policy, {added.index + 1} of {added.total} modules. + Runs before your policy, {added.index + 1} of {added.total} modules. )} @@ -252,5 +275,6 @@ export function ModulePage({ added, backLabel, module, needsInput, onAdd, onBack /** The open module's page over the center area, or else what it shows (`children`). */ export function ModulePageOr({ children, page }: { children: ReactNode; page?: ModulePageProps }) { - return page ? : children; + // A page per module: another one starts on its own tab. + return page ? : children; } diff --git a/src/renderer/src/mcp/testTools.ts b/src/renderer/src/mcp/testTools.ts index 6bdb478..4de78ea 100644 --- a/src/renderer/src/mcp/testTools.ts +++ b/src/renderer/src/mcp/testTools.ts @@ -88,7 +88,15 @@ const latestRelease = () => const contentOf = (state: RootState, project: Project) => toCfbsProject( - { canvas: state.canvas, derivedNodes: state.derivedNodes, edges: state.edges, files: state.files, groups: state.groups, testEnvironments: [] }, + { + canvas: state.canvas, + derivedNodes: state.derivedNodes, + edges: state.edges, + files: state.files, + groups: state.groups, + modules: state.modules, + testEnvironments: [] + }, project ); diff --git a/src/renderer/src/modules/catalog.ts b/src/renderer/src/modules/catalog.ts index 42871ac..80dc2f9 100644 --- a/src/renderer/src/modules/catalog.ts +++ b/src/renderer/src/modules/catalog.ts @@ -2,11 +2,25 @@ export type ModuleKind = 'configured' | 'library' | 'policy' | 'promise-type'; +// A list question's field (one per column of its rows; a single one for a list of texts). +export interface ModuleInputField { + default?: string; + key?: string; + label?: string; + question?: string; + type: string; +} + export interface ModuleInputQuestion { + default?: string; label?: string; question?: string; + // A list's fields: several (rows of named fields), or one (a list of texts). + subtype?: ModuleInputField | ModuleInputField[]; type: string; variable?: string; + // A list's "add another" question. + while?: string; } export interface CatalogModule { @@ -29,6 +43,24 @@ const isRecord = (value: unknown): value is Record => typeof va const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []); const text = (value: unknown) => (typeof value === 'string' ? value : undefined); +const inputField = (field: Record): ModuleInputField => ({ + default: text(field.default), + key: text(field.key), + label: text(field.label), + question: text(field.question), + type: text(field.type) ?? 'string' +}); + +function inputQuestion(question: Record): ModuleInputQuestion { + const { subtype } = question; + return { + ...inputField(question), + subtype: Array.isArray(subtype) ? subtype.filter(isRecord).map(inputField) : isRecord(subtype) ? inputField(subtype) : undefined, + variable: text(question.variable), + while: text(question.while) + }; +} + function kindOf(tags: string[], input: unknown[]): ModuleKind { if (tags.includes('promise-type')) return 'promise-type'; if (tags.includes('library')) return 'library'; @@ -48,12 +80,7 @@ export function parseIndex(index: Record): CatalogModule[] { dependencies: strings(entry.dependencies), description: text(entry.description) ?? '', experimental: tags.includes('experimental'), - input: input.map(question => ({ - label: text(question.label), - question: text(question.question), - type: text(question.type) ?? 'string', - variable: text(question.variable) - })), + input: input.map(inputQuestion), kind: kindOf(tags, input), name, repo: text(entry.repo), diff --git a/src/renderer/src/modules/moduleInput.test.ts b/src/renderer/src/modules/moduleInput.test.ts new file mode 100644 index 0000000..d04d2be --- /dev/null +++ b/src/renderer/src/modules/moduleInput.test.ts @@ -0,0 +1,44 @@ +import { indexModulesOf } from '../project/cfbsProject'; +import type { ModuleInputQuestion } from './catalog'; +import { isSupported, needsInput, newEntry } from './moduleInput'; + +const files: ModuleInputQuestion = { + type: 'list', + variable: 'files', + subtype: [ + { key: 'path', type: 'string' }, + { key: 'why', type: 'string', default: 'Unknown' } + ] +}; +const motd: ModuleInputQuestion = { type: 'string', variable: 'motd' }; + +describe('module input', () => { + it('needs input until every question is answered', () => { + expect(needsInput([files, motd], undefined)).toBe(true); + expect(needsInput([files, motd], { files: [], motd: 'Hi' })).toBe(true); + expect(needsInput([files, motd], { files: [{ path: '/a', why: 'x' }], motd: ' ' })).toBe(true); + expect(needsInput([files, motd], { files: [{ path: '/a', why: 'x' }], motd: 'Hi' })).toBe(false); + }); + + it('starts a list entry from its defaults; file inputs are not supported yet', () => { + expect(newEntry(files)).toEqual({ path: '', why: 'Unknown' }); + expect(newEntry({ type: 'list', variable: 'hosts', subtype: { type: 'string' } })).toBe(''); + expect(isSupported({ type: 'list', variable: 'playbooks', subtype: [{ key: 'file', type: 'file' }] })).toBe(false); + }); + + it('reads the project’s modules from cfbs.json, with their answers', () => { + const build = [ + { name: 'masterfiles', commit: 'a' }, + { name: 'library-for-promise-types-in-python', commit: 'b', version: '0.3.1', added_by: 'promise-type-git' }, + { name: 'promise-type-git', commit: 'c', added_by: 'cfbs add' }, + { name: 'delete-files', commit: 'd', added_by: 'cfbs add' }, + { name: './mods.cf', added_by: 'cfbs add', steps: [] } + ]; + const inputs = { 'delete-files': [{ variable: 'files', response: [{ path: '/a', why: 'x' }] }, { variable: 'unanswered' }] }; + expect(indexModulesOf(build, inputs)).toEqual([ + { name: 'library-for-promise-types-in-python', version: '0.3.1', addedBy: 'promise-type-git' }, + { name: 'promise-type-git' }, + { name: 'delete-files', input: { files: [{ path: '/a', why: 'x' }] } } + ]); + }); +}); diff --git a/src/renderer/src/modules/moduleInput.ts b/src/renderer/src/modules/moduleInput.ts new file mode 100644 index 0000000..7f0069e --- /dev/null +++ b/src/renderer/src/modules/moduleInput.ts @@ -0,0 +1,29 @@ +import type { ModuleInputResponse } from '../store/modulesSlice'; +import type { ModuleInputField, ModuleInputQuestion } from './catalog'; + +// The input types the form edits: text, and lists of texts or of rows of texts. +const isText = (field: ModuleInputField) => field.type === 'string'; + +/** Whether the builder can answer a question: cfbs's other types (files…) aren't supported yet. */ +export function isSupported(question: ModuleInputQuestion): boolean { + if (question.type === 'string') return true; + if (question.type !== 'list' || !question.subtype) return false; + return Array.isArray(question.subtype) ? question.subtype.every(isText) : isText(question.subtype); +} + +/** Whether a question has an answer cfbs can use: text, or a list with at least one entry. */ +export function isAnswered(question: ModuleInputQuestion, response: ModuleInputResponse | undefined): boolean { + if (question.type === 'string') return typeof response === 'string' && response.trim() !== ''; + return Array.isArray(response) && response.length > 0; +} + +/** Whether a module still needs input: one of its questions has no answer. */ +export const needsInput = (questions: ModuleInputQuestion[], answers: Record | undefined) => + questions.some(question => question.variable && !isAnswered(question, answers?.[question.variable])); + +/** A new list entry: the fields' defaults (a row), or the field's default (a text). */ +export function newEntry(question: ModuleInputQuestion): Record | string { + const { subtype } = question; + if (Array.isArray(subtype)) return Object.fromEntries(subtype.map(field => [field.key ?? '', field.default ?? ''])); + return subtype?.default ?? ''; +} diff --git a/src/renderer/src/pages/SideBar.tsx b/src/renderer/src/pages/SideBar.tsx index e7d1e42..d83e796 100644 --- a/src/renderer/src/pages/SideBar.tsx +++ b/src/renderer/src/pages/SideBar.tsx @@ -30,7 +30,7 @@ export function SideBar({ modules, onResize, onResizeEnd, policy, view, widthFra selectedName={modules.openName} isNeedingInput={modules.isNeedingInput} onSelect={modules.open} - onEditInput={modules.open} + onEditInput={modules.openInput} onAdd={modules.add} onRemove={modules.remove} /> diff --git a/src/renderer/src/pages/useProjectModules.ts b/src/renderer/src/pages/useProjectModules.ts index 1f07333..9892868 100644 --- a/src/renderer/src/pages/useProjectModules.ts +++ b/src/renderer/src/pages/useProjectModules.ts @@ -1,10 +1,11 @@ import { useState } from 'react'; import { modulesToAdd, modulesToRemove } from '../modules/catalog'; +import { needsInput } from '../modules/moduleInput'; import { useModuleCatalog } from '../modules/useModuleCatalog'; import { useModuleDetails } from '../modules/useModuleDetails'; import { useAppDispatch, useAppSelector } from '../store'; -import { modulesAdded, modulesRemoved } from '../store/modulesSlice'; +import { type ModuleInputResponse, moduleInputChanged, modulesAdded, modulesRemoved } from '../store/modulesSlice'; /** * The project's CFEngine Build modules, and the one whose page is open over the center area. @@ -14,15 +15,23 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { const dispatch = useAppDispatch(); const projectModules = useAppSelector(state => state.modules); const [openName, setOpenName] = useState(null); + const [openInput, setOpenInput] = useState(false); const catalog = useModuleCatalog(viewOpen || openName !== null); const open = openName ? catalog.byName.get(openName) : undefined; const web = useModuleDetails(open); - // Input isn't stored yet: a module that asks for some needs it. - const isNeedingInput = (name: string) => (catalog.byName.get(name)?.input.length ?? 0) > 0; + // An added module with a question unanswered. + const isNeedingInput = (name: string) => { + const questions = catalog.byName.get(name)?.input ?? []; + return questions.length > 0 && needsInput(questions, projectModules.find(module => module.name === name)?.input); + }; const add = (name: string) => dispatch(modulesAdded(modulesToAdd(catalog.byName, name, projectModules))); const remove = (name: string) => dispatch(modulesRemoved(modulesToRemove(catalog.byName, name, projectModules))); const close = () => setOpenName(null); + const openPage = (name: string | null, input = false) => { + setOpenName(name); + setOpenInput(input); + }; const index = openName ? projectModules.findIndex(module => module.name === openName) : -1; const page = open && { @@ -40,7 +49,9 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { onAdd: () => add(open.name), onRemove: () => remove(open.name), onBack: close, - onOpenModule: setOpenName + onInputChange: (variable: string, response: ModuleInputResponse) => dispatch(moduleInputChanged({ name: open.name, variable, response })), + onOpenModule: (name: string) => openPage(name), + openInput }; return { @@ -49,7 +60,8 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { close, isNeedingInput, needingInput: projectModules.filter(module => isNeedingInput(module.name)).length, - open: setOpenName, + open: (name: string) => openPage(name), + openInput: (name: string) => openPage(name, true), openName, page, projectModules, diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index 26d88fe..c942e2f 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -9,6 +9,7 @@ import { deriveNamespace } from '../store/filesSlice/deriveNamespace'; import type { PolicyFile, PolicyFolder } from '../store/filesSlice/types'; import type { BlockGroup } from '../store/groupsSlice/types'; import type { UndoableKey } from '../store/history'; +import type { ModuleInputResponse, ProjectModule } from '../store/modulesSlice'; import type { ProjectType } from '../store/projectSlice/types'; import type { TestEnvironment } from '../store/testEnvironmentsSlice/types'; import { moduleNameFor } from './moduleName'; @@ -84,6 +85,9 @@ export interface ProvidedModule { } export interface CfbsProjectContent { + // The CFEngine Build modules (a policy set's): saving adds and removes them with cfbs, and + // writes their input answers to /input.json. + buildModules: { addedBy?: string; input?: Record; name: string }[]; // cfbs.json `build` entries, when stored as a policy set. modules: PolicyModule[]; // .policy-builder/project.json. @@ -263,6 +267,7 @@ export function toCfbsProject(data: ProjectData, identity: Identity): CfbsProjec }, modules: [...modules].map(([name, bundles]) => toModule(name, bundles)), provided: toProvided(modules, identity), + buildModules: data.modules.map(({ addedBy, input, name }) => ({ name, ...(addedBy ? { addedBy } : {}), ...(input ? { input } : {}) })), testEnvironments: data.testEnvironments }; } @@ -357,7 +362,8 @@ function readBuilderProject(project: ProjectMeta): ProjectData { edges, files: { currentFileId, files, folders: folders.map(({ id, name, parentId }) => ({ id, name, parentId })) }, groups, - // Read from their own file; see loadCfbsProject. + // Read from cfbs.json and their own files; see loadCfbsProject. + modules: [], testEnvironments: [] }; } @@ -440,7 +446,13 @@ const ownPolicyFiles = (build: unknown[]) => ]) .flatMap(path => (typeof path === 'string' && /^\.\/[^/]+\.cf$/i.test(path) ? [path.slice(2, -3).toLowerCase()] : [])); -export function loadCfbsProject(json: unknown, builder: unknown, folderName: string, testEnvironments: unknown = null): LoadedProject { +export function loadCfbsProject( + json: unknown, + builder: unknown, + folderName: string, + testEnvironments: unknown = null, + moduleInputs: unknown = null +): LoadedProject { if (!isObject(json)) throw new Error('cfbs.json is not a JSON object'); const type: ProjectType = json.type === 'module' ? 'module' : 'policy-set'; const cfbsName = (typeof json.name === 'string' && json.name.trim()) || folderName; @@ -451,10 +463,39 @@ export function loadCfbsProject(json: unknown, builder: unknown, folderName: str const identity = { description: typeof json.description === 'string' ? json.description : '', moduleName, name, type }; const masterfiles = masterfilesOf(Array.isArray(json.build) ? json.build : []); const environments = environmentsFrom(testEnvironments); + const modules = indexModulesOf(Array.isArray(json.build) ? json.build : [], moduleInputs); const data = builder === null || builder === undefined ? null : fromBuilderProject(builder); - if (data && data.files.files.length > 0) return { ...identity, data: { ...data, testEnvironments: environments }, masterfiles }; + if (data && data.files.files.length > 0) return { ...identity, data: { ...data, modules, testEnvironments: environments }, masterfiles }; const files = filesReducer(undefined, projectFilesInitialized(name, ownPolicyFiles(Array.isArray(json.build) ? json.build : []))); - return { ...identity, data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [], testEnvironments: environments }, masterfiles }; + return { ...identity, data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [], modules, testEnvironments: environments }, masterfiles }; +} + +// The input answers in a module's input.json (its questions, each with a `response`), by variable. +function answersOf(input: unknown): Record | undefined { + if (!Array.isArray(input)) return undefined; + const answers = Object.fromEntries( + input + .filter(isObject) + .flatMap(question => + typeof question.variable === 'string' && 'response' in question ? [[question.variable, question.response as ModuleInputResponse]] : [] + ) + ); + return Object.keys(answers).length ? answers : undefined; +} + +/** + * The CFEngine Build modules in a cfbs.json `build`, in its order: entries from the index carry + * their commit (the builder's own and local modules don't); masterfiles isn't one of them. + */ +export function indexModulesOf(build: unknown[], moduleInputs: unknown): ProjectModule[] { + const inputs = isObject(moduleInputs) ? moduleInputs : {}; + return build.filter(isObject).flatMap(entry => { + const { name } = entry; + if (typeof name !== 'string' || name === 'masterfiles' || typeof entry.commit !== 'string') return []; + const addedBy = typeof entry.added_by === 'string' && entry.added_by !== 'cfbs add' ? entry.added_by : undefined; + const input = answersOf(inputs[name]); + return [{ name, ...(typeof entry.version === 'string' ? { version: entry.version } : {}), ...(addedBy ? { addedBy } : {}), ...(input ? { input } : {}) }]; + }); } /** The project folder's name for a project name: "Web Server Hardening" → "web-server-hardening". */ diff --git a/src/renderer/src/project/projectChanges.test.ts b/src/renderer/src/project/projectChanges.test.ts index 5e24eef..4e47760 100644 --- a/src/renderer/src/project/projectChanges.test.ts +++ b/src/renderer/src/project/projectChanges.test.ts @@ -27,6 +27,7 @@ const project = ({ derivedNodes: {}, edges, files: { currentFileId: null, files, folders: [] }, + modules: [], groups, testEnvironments: [] }); diff --git a/src/renderer/src/project/useCompiledPolicy.ts b/src/renderer/src/project/useCompiledPolicy.ts index 9a4dbcf..3fc8368 100644 --- a/src/renderer/src/project/useCompiledPolicy.ts +++ b/src/renderer/src/project/useCompiledPolicy.ts @@ -37,7 +37,7 @@ export function useCompiledPolicy(enabled: boolean): CompiledPolicyState { const groups = useAppSelector(state => state.groups); const meta = useMemo( - () => (project ? toCfbsProject({ canvas, derivedNodes, edges, files, groups, testEnvironments: [] }, project).project : null), + () => (project ? toCfbsProject({ canvas, derivedNodes, edges, files, groups, modules: [], testEnvironments: [] }, project).project : null), [project, canvas, derivedNodes, edges, files, groups] ); // What the compiler is sent. diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts index a457099..cd00fa2 100644 --- a/src/renderer/src/project/useProjectSession.ts +++ b/src/renderer/src/project/useProjectSession.ts @@ -19,6 +19,7 @@ const snapshotOf = (state: RootState): ProjectData => ({ edges: state.edges, files: state.files, groups: state.groups, + modules: state.modules, testEnvironments: state.testEnvironments }); @@ -188,7 +189,7 @@ export function useProjectSession() { const result = await window.api.openProject(path ? { path } : {}); if (!result) return; if (!result.ok) throw new Error(result.message); - const { data, ...project } = loadCfbsProject(result.cfbs, result.builder, folderNameOf(result.path), result.testEnvironments); + const { data, ...project } = loadCfbsProject(result.cfbs, result.builder, folderNameOf(result.path), result.testEnvironments, result.moduleInputs); dispatch(projectLoaded({ ...project, path: result.path }, data)); dispatch(historyCleared()); markSaved(); diff --git a/src/renderer/src/store/history.ts b/src/renderer/src/store/history.ts index b40a0b1..9ae5c1d 100644 --- a/src/renderer/src/store/history.ts +++ b/src/renderer/src/store/history.ts @@ -19,7 +19,7 @@ export function inOneStep(dispatch: Dispatch, run: () => void) { } const LIMIT = 100; -export const UNDOABLE_KEYS = ['canvas', 'derivedNodes', 'edges', 'files', 'groups', 'testEnvironments'] as const; +export const UNDOABLE_KEYS = ['canvas', 'derivedNodes', 'edges', 'files', 'groups', 'modules', 'testEnvironments'] as const; export type UndoableKey = (typeof UNDOABLE_KEYS)[number]; // Text edits dispatch per keystroke: consecutive ones to the same field merge @@ -38,7 +38,8 @@ const TYPING_FIELDS: Record = { 'files/fileDescriptionChanged': 'description', 'groups/groupConditionClassNameChanged': 'className', 'groups/groupRenamed': 'name', - 'testEnvironments/hostRenamed': 'name' + 'testEnvironments/hostRenamed': 'name', + 'modules/moduleInputChanged': 'response' }; // Change undoable state without being worth a step of their own. const UNTRACKED = new Set(['files/fileSelected']); diff --git a/src/renderer/src/store/modulesSlice/index.ts b/src/renderer/src/store/modulesSlice/index.ts index d8be08f..997c1fa 100644 --- a/src/renderer/src/store/modulesSlice/index.ts +++ b/src/renderer/src/store/modulesSlice/index.ts @@ -2,9 +2,14 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; import { projectCreated, projectLoaded } from '../projectSlice'; +// An input question's answer: text, a list of texts, or a list's rows (field key → text). +export type ModuleInputResponse = Record[] | string[] | string; + /** A CFEngine Build module in the project, in build order; a dependency names the module it came with. */ export interface ProjectModule { addedBy?: string; + // Its input questions' answers, by variable (saved as /input.json). + input?: Record; name: string; version?: string; } @@ -18,12 +23,16 @@ const modulesSlice = createSlice({ const present = new Set(state.map(module => module.name)); state.push(...action.payload.filter(module => !present.has(module.name))); }, - modulesRemoved: (state, action: PayloadAction) => state.filter(module => !action.payload.includes(module.name)) + modulesRemoved: (state, action: PayloadAction) => state.filter(module => !action.payload.includes(module.name)), + moduleInputChanged: (state, action: PayloadAction<{ name: string; response: ModuleInputResponse; variable: string }>) => { + const module = state.find(candidate => candidate.name === action.payload.name); + if (module) module.input = { ...module.input, [action.payload.variable]: action.payload.response }; + } }, extraReducers: builder => { - builder.addCase(projectCreated, () => []).addCase(projectLoaded, () => []); + builder.addCase(projectCreated, () => []).addCase(projectLoaded, (_state, action) => action.payload.content.modules ?? []); } }); -export const { modulesAdded, modulesRemoved } = modulesSlice.actions; +export const { moduleInputChanged, modulesAdded, modulesRemoved } = modulesSlice.actions; export default modulesSlice.reducer; diff --git a/src/renderer/src/store/projectSlice/types.ts b/src/renderer/src/store/projectSlice/types.ts index 980a40d..d07de6f 100644 --- a/src/renderer/src/store/projectSlice/types.ts +++ b/src/renderer/src/store/projectSlice/types.ts @@ -2,6 +2,7 @@ import type { BlockInstance } from '../canvasSlice/types'; import type { BlockEdge } from '../edgesSlice/types'; import type { PolicyFile, PolicyFolder } from '../filesSlice/types'; import type { BlockGroup } from '../groupsSlice/types'; +import type { ProjectModule } from '../modulesSlice'; import type { TestEnvironment } from '../testEnvironmentsSlice/types'; // How the project is stored: a policy set (masterfiles plus this project, built with @@ -30,5 +31,6 @@ export interface ProjectContentState { edges: BlockEdge[]; files: { currentFileId: string | null; files: PolicyFile[]; folders: PolicyFolder[] }; groups: BlockGroup[]; + modules?: ProjectModule[]; testEnvironments: TestEnvironment[]; } From b8caabf329b4bbb1de33a86470b0d47fefbc89f7 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 16:55:24 +0300 Subject: [PATCH 03/23] Added Git Checkout, Systemd Unit, HTTP Request and Run Ansible Playbook blocks They use the git, systemd, http and ansible promise types from CFEngine Build modules: adding one of these blocks adds its module to the project, which can't be removed while a block uses it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/ansible-playbook.json | 101 +++++++++ blocks/git-checkout.json | 98 +++++++++ blocks/http-request.json | 101 +++++++++ blocks/schemas/block-descriptor.v1.json | 13 +- blocks/systemd-unit.json | 206 ++++++++++++++++++ python/tests/snapshots/ansible-playbook.cf | 13 ++ python/tests/snapshots/git-checkout.cf | 26 +++ python/tests/snapshots/http-request.cf | 53 +++++ python/tests/snapshots/systemd-unit.cf | 148 +++++++++++++ python/tests/test_compiler_all_blocks.py | 7 +- python/tests/test_compiler_block_snapshots.py | 26 +++ src/main/mcpTools/guide.ts | 4 + src/renderer/src/blocks/loadBlocks.test.ts | 2 +- src/renderer/src/blocks/loadBlocks.ts | 1 + src/renderer/src/blocks/promiseTypeIcons.tsx | 11 +- src/renderer/src/blocks/types.ts | 20 +- .../src/components/modules/ModulePage.tsx | 6 +- .../src/components/modules/ModuleRow.tsx | 12 +- .../src/components/modules/ModulesView.tsx | 6 +- src/renderer/src/mcp/canvasTools.ts | 8 +- src/renderer/src/pages/SideBar.tsx | 1 + src/renderer/src/pages/useProjectModules.ts | 8 +- src/renderer/src/store/index.ts | 41 ++-- .../src/store/modulesSlice/index.test.ts | 24 ++ 24 files changed, 906 insertions(+), 30 deletions(-) create mode 100644 blocks/ansible-playbook.json create mode 100644 blocks/git-checkout.json create mode 100644 blocks/http-request.json create mode 100644 blocks/systemd-unit.json create mode 100644 python/tests/snapshots/ansible-playbook.cf create mode 100644 python/tests/snapshots/git-checkout.cf create mode 100644 python/tests/snapshots/http-request.cf create mode 100644 python/tests/snapshots/systemd-unit.cf create mode 100644 src/renderer/src/store/modulesSlice/index.test.ts diff --git a/blocks/ansible-playbook.json b/blocks/ansible-playbook.json new file mode 100644 index 0000000..a67b959 --- /dev/null +++ b/blocks/ansible-playbook.json @@ -0,0 +1,101 @@ +{ + "$schema": "./schemas/block-descriptor.v1.json", + "schema_version": 1, + "id": "ansible-playbook", + "version": 1, + "name": "Run Ansible Playbook", + "category": "From modules", + "description": "Runs an Ansible playbook on the host (connection local by default).", + "compile_target": "own_bundle", + "requires_module": "promise-type-ansible", + "steps": [ + { + "promise_type": "ansible", + "promiser": "{{playbook}}", + "attributes": { + "inventory": { + "if_set": "inventory", + "value": "{{inventory}}" + }, + "tags": { + "if_set": "tags", + "value": { + "list_param": "tags" + } + }, + "limit": { + "if_set": "limit", + "value": { + "list_param": "limit" + } + }, + "become": "{{become}}", + "become_user": { + "if_set": "become_user", + "value": "{{become_user}}" + } + } + } + ], + "parameters": [ + { + "name": "playbook", + "label": "Playbook", + "type": "string", + "required": true, + "path": "absolute", + "help": "Its absolute path on the host, e.g. /srv/ansible/site.yml. Needs Ansible on the host." + }, + { + "name": "inventory", + "label": "Inventory", + "type": "string", + "required": false, + "default": "", + "path": "absolute" + }, + { + "name": "tags", + "label": "Tags", + "type": "text", + "required": false, + "default": "", + "allow_list": true, + "help": "Only the plays and tasks with these tags, one per line." + }, + { + "name": "limit", + "label": "Limit to hosts", + "type": "text", + "required": false, + "default": "", + "allow_list": true, + "help": "Hosts or groups, one per line." + }, + { + "name": "become", + "label": "Become", + "type": "string", + "required": true, + "default": "false", + "options": [ + { + "value": "false", + "label": "No" + }, + { + "value": "true", + "label": "Yes (sudo)" + } + ] + }, + { + "name": "become_user", + "label": "Become user", + "type": "string", + "required": false, + "default": "", + "help": "Empty: root." + } + ] +} diff --git a/blocks/git-checkout.json b/blocks/git-checkout.json new file mode 100644 index 0000000..607b3cf --- /dev/null +++ b/blocks/git-checkout.json @@ -0,0 +1,98 @@ +{ + "$schema": "./schemas/block-descriptor.v1.json", + "schema_version": 1, + "id": "git-checkout", + "version": 1, + "name": "Git Checkout", + "category": "From modules", + "description": "Clones a git repository into a folder and keeps it at a branch, tag or commit.", + "compile_target": "own_bundle", + "requires_module": "promise-type-git", + "steps": [ + { + "promise_type": "git", + "promiser": "{{destination}}", + "attributes": { + "repository": "{{repository}}", + "version": { + "if_set": "version", + "value": "{{version}}" + }, + "update": "{{update}}", + "force": "{{force}}", + "depth": { + "if_set": "depth", + "value": "{{depth}}" + } + } + } + ], + "parameters": [ + { + "name": "destination", + "label": "Folder", + "type": "string", + "required": true, + "path": "absolute", + "help": "Where the repository is cloned, e.g. /srv/app. Needs git on the host." + }, + { + "name": "repository", + "label": "Repository URL", + "type": "string", + "required": true, + "help": "E.g. https://github.com/cfengine/starter_pack. A private one needs credentials for root (gitcredentials)." + }, + { + "name": "version", + "label": "Branch, tag or commit", + "type": "string", + "required": false, + "default": "", + "help": "Empty: the remote's default branch." + }, + { + "name": "update", + "label": "Keep it updated", + "type": "string", + "required": true, + "default": "true", + "options": [ + { + "value": "true", + "label": "Yes: pull new commits" + }, + { + "value": "false", + "label": "No: clone once" + } + ] + }, + { + "name": "force", + "label": "Local changes", + "type": "string", + "required": true, + "default": "false", + "options": [ + { + "value": "false", + "label": "Keep them" + }, + { + "value": "true", + "label": "Discard them" + } + ] + }, + { + "name": "depth", + "label": "History depth", + "type": "number", + "required": false, + "integer": true, + "minimum": 1, + "help": "Empty: the full history. 1: the latest commit only." + } + ] +} diff --git a/blocks/http-request.json b/blocks/http-request.json new file mode 100644 index 0000000..66ac457 --- /dev/null +++ b/blocks/http-request.json @@ -0,0 +1,101 @@ +{ + "$schema": "./schemas/block-descriptor.v1.json", + "schema_version": 1, + "id": "http-request", + "version": 1, + "name": "HTTP Request", + "category": "From modules", + "description": "Sends an HTTP(S) request, e.g. to download a file or call an API.", + "compile_target": "own_bundle", + "requires_module": "promise-type-http", + "steps": [ + { + "promise_type": "http", + "promiser": "{{url}}", + "attributes": { + "method": "{{method}}", + "file": { + "if_set": "file", + "value": "{{file}}" + }, + "headers": { + "if_set": "headers", + "value": { + "list_param": "headers" + } + }, + "payload": { + "if_set": "payload", + "value": "{{payload}}" + }, + "insecure": "{{insecure}}" + } + } + ], + "parameters": [ + { + "name": "url", + "label": "URL", + "type": "string", + "required": true, + "help": "Starting with https:// or http://." + }, + { + "name": "method", + "label": "Method", + "type": "string", + "required": true, + "default": "GET", + "options": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ] + }, + { + "name": "file", + "label": "Save the response to", + "type": "string", + "required": false, + "default": "", + "path": "absolute", + "help": "Empty: the response is dropped (an OK status keeps the promise). The request runs on every agent run: set How often, or a condition." + }, + { + "name": "headers", + "label": "Headers", + "type": "text", + "required": false, + "default": "", + "allow_list": true, + "help": "One per line, Name: value." + }, + { + "name": "payload", + "label": "Body", + "type": "text", + "required": false, + "default": "", + "help": "Sent as is; @/some/path sends a file." + }, + { + "name": "insecure", + "label": "Server certificate", + "type": "string", + "required": true, + "default": "false", + "options": [ + { + "value": "false", + "label": "Checked" + }, + { + "value": "true", + "label": "Not checked (insecure)" + } + ] + } + ] +} diff --git a/blocks/schemas/block-descriptor.v1.json b/blocks/schemas/block-descriptor.v1.json index 83baa2a..0661526 100644 --- a/blocks/schemas/block-descriptor.v1.json +++ b/blocks/schemas/block-descriptor.v1.json @@ -70,6 +70,10 @@ "type": "string", "description": "Palette grouping." }, + "requires_module": { + "type": "string", + "description": "The CFEngine Build module providing the block's custom promise type: adding the block adds the module to the project." + }, "compile_target": { "enum": [ "own_bundle", @@ -221,8 +225,13 @@ "services", "commands", "storage", - "reports" - ] + "reports", + "git", + "systemd", + "http", + "ansible" + ], + "description": "git, systemd, http and ansible are custom promise types: the block's requires_module provides them." }, "promiser": { "$ref": "common.v1.json#/$defs/template", diff --git a/blocks/systemd-unit.json b/blocks/systemd-unit.json new file mode 100644 index 0000000..6725418 --- /dev/null +++ b/blocks/systemd-unit.json @@ -0,0 +1,206 @@ +{ + "$schema": "./schemas/block-descriptor.v1.json", + "schema_version": 1, + "id": "systemd-unit", + "version": 1, + "name": "Systemd Unit", + "category": "From modules", + "description": "Keeps a systemd service running or stopped, and can write its unit file.", + "compile_target": "own_bundle", + "requires_module": "promise-type-systemd", + "steps": [ + { + "promise_type": "systemd", + "promiser": "{{name}}", + "attributes": { + "state": "{{state}}", + "enabled": "{{enabled}}", + "daemon_reload": "{{daemon_reload}}", + "unit_description": { + "if_set": "unit_description", + "value": "{{unit_description}}" + }, + "service_exec_start": { + "if_set": "exec_start", + "value": { + "list_param": "exec_start" + } + }, + "service_type": { + "if_set": "service_type", + "value": "{{service_type}}" + }, + "service_user": { + "if_set": "service_user", + "value": "{{service_user}}" + }, + "service_restart": { + "if_set": "restart", + "value": "{{restart}}" + }, + "unit_after": { + "if_set": "after", + "value": { + "list_param": "after" + } + } + } + } + ], + "parameters": [ + { + "name": "name", + "label": "Service name", + "type": "string", + "required": true, + "help": "E.g. myapp (myapp.service)." + }, + { + "name": "state", + "label": "State", + "type": "string", + "required": true, + "default": "started", + "options": [ + { + "value": "started", + "label": "Running" + }, + { + "value": "stopped", + "label": "Stopped" + }, + { + "value": "absent", + "label": "Removed (with its unit file)" + }, + { + "value": "restart", + "label": "Restarted on every run" + }, + { + "value": "reload", + "label": "Reloaded on every run" + } + ] + }, + { + "name": "enabled", + "label": "Start on boot", + "type": "string", + "required": true, + "default": "true", + "options": [ + { + "value": "true", + "label": "Yes" + }, + { + "value": "false", + "label": "No" + } + ] + }, + { + "name": "daemon_reload", + "label": "Reload systemd first", + "type": "string", + "required": true, + "default": "false", + "options": [ + { + "value": "true", + "label": "Yes" + }, + { + "value": "false", + "label": "No" + } + ], + "help": "daemon-reload before acting, e.g. after a unit file changed outside this block." + }, + { + "name": "unit_description", + "label": "Description", + "type": "string", + "required": false, + "default": "", + "help": "Setting any unit field below writes the unit file (/etc/systemd/system/.service)." + }, + { + "name": "exec_start", + "label": "Start commands", + "type": "text", + "required": false, + "default": "", + "allow_list": true, + "help": "ExecStart, one per line, e.g. /usr/bin/myapp --port 8080." + }, + { + "name": "service_type", + "label": "Type", + "type": "string", + "required": false, + "default": "", + "options": [ + { + "value": "", + "label": "Default (simple)" + }, + { + "value": "simple", + "label": "simple" + }, + { + "value": "forking", + "label": "forking" + }, + { + "value": "oneshot", + "label": "oneshot" + }, + { + "value": "notify", + "label": "notify" + } + ] + }, + { + "name": "service_user", + "label": "Run as user", + "type": "string", + "required": false, + "default": "" + }, + { + "name": "restart", + "label": "Restart", + "type": "string", + "required": false, + "default": "", + "options": [ + { + "value": "", + "label": "Never" + }, + { + "value": "on-failure", + "label": "On failure" + }, + { + "value": "always", + "label": "Always" + } + ] + }, + { + "name": "after", + "label": "Start after", + "type": "text", + "required": false, + "default": "", + "allow_list": true, + "help": "Units, one per line, e.g. network-online.target." + } + ] +} diff --git a/python/tests/snapshots/ansible-playbook.cf b/python/tests/snapshots/ansible-playbook.cf new file mode 100644 index 0000000..71fc213 --- /dev/null +++ b/python/tests/snapshots/ansible-playbook.cf @@ -0,0 +1,13 @@ +### example + # Case 0 + ansible: + "/srv/ansible/site.yml" + tags => { "web", "db" }, + become => "false"; + +### become = 'true' + # Case 1 + ansible: + "/srv/ansible/site.yml" + tags => { "web", "db" }, + become => "true"; diff --git a/python/tests/snapshots/git-checkout.cf b/python/tests/snapshots/git-checkout.cf new file mode 100644 index 0000000..683026c --- /dev/null +++ b/python/tests/snapshots/git-checkout.cf @@ -0,0 +1,26 @@ +### example + # Case 0 + git: + "/srv/app" + repository => "https://github.com/cfengine/starter_pack", + version => "main", + update => "true", + force => "false"; + +### update = 'false' + # Case 1 + git: + "/srv/app" + repository => "https://github.com/cfengine/starter_pack", + version => "main", + update => "false", + force => "false"; + +### force = 'true' + # Case 2 + git: + "/srv/app" + repository => "https://github.com/cfengine/starter_pack", + version => "main", + update => "true", + force => "true"; diff --git a/python/tests/snapshots/http-request.cf b/python/tests/snapshots/http-request.cf new file mode 100644 index 0000000..a78a67a --- /dev/null +++ b/python/tests/snapshots/http-request.cf @@ -0,0 +1,53 @@ +### example + # Case 0 + http: + "https://cfengine.com/images/cfengine-logo.svg" + method => "GET", + file => "/var/cfengine/logo.svg", + headers => { "Accept: image/svg+xml" }, + insecure => "false"; + +### method = 'POST' + # Case 1 + http: + "https://cfengine.com/images/cfengine-logo.svg" + method => "POST", + file => "/var/cfengine/logo.svg", + headers => { "Accept: image/svg+xml" }, + insecure => "false"; + +### method = 'PUT' + # Case 2 + http: + "https://cfengine.com/images/cfengine-logo.svg" + method => "PUT", + file => "/var/cfengine/logo.svg", + headers => { "Accept: image/svg+xml" }, + insecure => "false"; + +### method = 'DELETE' + # Case 3 + http: + "https://cfengine.com/images/cfengine-logo.svg" + method => "DELETE", + file => "/var/cfengine/logo.svg", + headers => { "Accept: image/svg+xml" }, + insecure => "false"; + +### method = 'PATCH' + # Case 4 + http: + "https://cfengine.com/images/cfengine-logo.svg" + method => "PATCH", + file => "/var/cfengine/logo.svg", + headers => { "Accept: image/svg+xml" }, + insecure => "false"; + +### insecure = 'true' + # Case 5 + http: + "https://cfengine.com/images/cfengine-logo.svg" + method => "GET", + file => "/var/cfengine/logo.svg", + headers => { "Accept: image/svg+xml" }, + insecure => "true"; diff --git a/python/tests/snapshots/systemd-unit.cf b/python/tests/snapshots/systemd-unit.cf new file mode 100644 index 0000000..fd0f149 --- /dev/null +++ b/python/tests/snapshots/systemd-unit.cf @@ -0,0 +1,148 @@ +### example + # Case 0 + systemd: + "myapp" + state => "started", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + unit_after => { "network-online.target" }; + +### state = 'stopped' + # Case 1 + systemd: + "myapp" + state => "stopped", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + unit_after => { "network-online.target" }; + +### state = 'absent' + # Case 2 + systemd: + "myapp" + state => "absent", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + unit_after => { "network-online.target" }; + +### state = 'restart' + # Case 3 + systemd: + "myapp" + state => "restart", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + unit_after => { "network-online.target" }; + +### state = 'reload' + # Case 4 + systemd: + "myapp" + state => "reload", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + unit_after => { "network-online.target" }; + +### enabled = 'false' + # Case 5 + systemd: + "myapp" + state => "started", + enabled => "false", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + unit_after => { "network-online.target" }; + +### daemon_reload = 'true' + # Case 6 + systemd: + "myapp" + state => "started", + enabled => "true", + daemon_reload => "true", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + unit_after => { "network-online.target" }; + +### service_type = 'simple' + # Case 7 + systemd: + "myapp" + state => "started", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + service_type => "simple", + unit_after => { "network-online.target" }; + +### service_type = 'forking' + # Case 8 + systemd: + "myapp" + state => "started", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + service_type => "forking", + unit_after => { "network-online.target" }; + +### service_type = 'oneshot' + # Case 9 + systemd: + "myapp" + state => "started", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + service_type => "oneshot", + unit_after => { "network-online.target" }; + +### service_type = 'notify' + # Case 10 + systemd: + "myapp" + state => "started", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + service_type => "notify", + unit_after => { "network-online.target" }; + +### restart = 'on-failure' + # Case 11 + systemd: + "myapp" + state => "started", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + service_restart => "on-failure", + unit_after => { "network-online.target" }; + +### restart = 'always' + # Case 12 + systemd: + "myapp" + state => "started", + enabled => "true", + daemon_reload => "false", + unit_description => "My app", + service_exec_start => { "/usr/bin/myapp --port 8080" }, + service_restart => "always", + unit_after => { "network-online.target" }; diff --git a/python/tests/test_compiler_all_blocks.py b/python/tests/test_compiler_all_blocks.py index b1547f8..7b9bc7c 100644 --- a/python/tests/test_compiler_all_blocks.py +++ b/python/tests/test_compiler_all_blocks.py @@ -196,9 +196,14 @@ def test_every_block_source_and_decorator_passes_cf_promises_and_lint(tmp_path: (tmp_path / path).parent.mkdir(parents=True, exist_ok=True) (tmp_path / path).write_text(text) shutil.copy(FIXTURES / "stdlib-stub.cf", tmp_path / "stdlib.cf") + # Custom promise types are declared by their modules' enable.cf (cfbs appends it to services/init.cf). + declared = "".join( + f'promise agent {kind} {{ path => "/var/cfengine/modules/promises/{kind}.py"; interpreter => "/usr/bin/python3"; }}\n' + for kind in ("git", "systemd", "http", "ansible") + ) (tmp_path / "promises.cf").write_text( 'body common control { inputs => { "stdlib.cf", "all.cf" }; bundlesequence => { "all:main" }; }\n' - "bundle agent some_bundle(one) { }\n" + "bundle agent some_bundle(one) { }\n" + declared ) result = subprocess.run(["cf-promises", "-f", str(tmp_path / "promises.cf")], capture_output=True, text=True) diff --git a/python/tests/test_compiler_block_snapshots.py b/python/tests/test_compiler_block_snapshots.py index 336c415..cec5a8c 100644 --- a/python/tests/test_compiler_block_snapshots.py +++ b/python/tests/test_compiler_block_snapshots.py @@ -31,7 +31,26 @@ "create-edit-file": {"path": "/etc/app.conf", "content": 'name = "app"\nport = 8080\n'}, "create-symlink": {"link": "/etc/nginx/sites-enabled/app", "target": "/etc/nginx/sites-available/app"}, "cron-job": {"command": "/usr/local/bin/backup.sh", "hours": "3", "minutes": "30"}, + "ansible-playbook": { + "playbook": "/srv/ansible/site.yml", + "tags": "web\ndb", + "become_user": "", + "inventory": "", + "limit": "", + }, "ensure-lines": {"path": "/etc/hosts", "lines": "127.0.0.1 app.local\n10.0.0.5 db.local"}, + "git-checkout": { + "destination": "/srv/app", + "repository": "https://github.com/cfengine/starter_pack", + "version": "main", + "depth": "", + }, + "http-request": { + "url": "https://cfengine.com/images/cfengine-logo.svg", + "file": "/var/cfengine/logo.svg", + "headers": "Accept: image/svg+xml", + "payload": "", + }, "install-package": {"package_name": "nginx", "version": ""}, "manage-service": {"service_name": "nginx"}, "manage-users": {"username": "deploy"}, @@ -48,6 +67,13 @@ "set-config-values": {"path": "/etc/ssh/sshd_config", "settings": "PermitRootLogin no\nMaxAuthTries 3"}, "set-permissions": {"path": "/etc/shadow", "mode": "640", "group": "shadow"}, "signal-process": {"process_pattern": "nginx: master.*"}, + "systemd-unit": { + "name": "myapp", + "exec_start": "/usr/bin/myapp --port 8080", + "after": "network-online.target", + "unit_description": "My app", + "service_user": "", + }, "watch-file": {"path": "/etc/passwd\n/etc/group"}, } diff --git a/src/main/mcpTools/guide.ts b/src/main/mcpTools/guide.ts index a9c5876..b624d0f 100644 --- a/src/main/mcpTools/guide.ts +++ b/src/main/mcpTools/guide.ts @@ -57,6 +57,10 @@ const blocks = `# Blocks, arrows and groups - group_blocks puts blocks into a frame that runs as one step (its own bundle). Arrows into and out of the group attach to its frame; an arrow out of a group reads the group's combined outcome. - Use groups for a set of blocks that belong together ("Configure nginx"), and set_group_condition to gate them all. +## Blocks from CFEngine Build modules +- Git Checkout (git-checkout), Systemd Unit (systemd-unit), HTTP Request (http-request) and Run Ansible Playbook (ansible-playbook) are custom promise types (git:, systemd:, http:, ansible:). Adding one adds its module (promise-type-git…) to the project; saving installs it with cfbs (needs the internet once). Hosts need python3, and git or Ansible for those blocks. +- Use them rather than Run Command for cloning repositories, systemd units, HTTP requests and playbooks: they report kept/repaired properly, so arrows and How often work. + ## Custom CFEngine (hand-written promises) - For what no block covers (a custom body, a promise type without a block, an attribute no block offers), the Custom CFEngine block (custom-cfengine) holds CFEngine text: \`promises\` (promise sections as in a bundle agent, e.g. \`files:\` then its promises) and \`definitions\` (the bodies and bundles they use). It runs as a bundle of its own, in order, with the block's condition, arrows, How often and note like any block. - **Ask the user before using it**, and only for the part no block covers: the rest stays in blocks they can read and edit. Say in the block's note what it does and why it's hand-written. diff --git a/src/renderer/src/blocks/loadBlocks.test.ts b/src/renderer/src/blocks/loadBlocks.test.ts index 8b76d2e..7dc9232 100644 --- a/src/renderer/src/blocks/loadBlocks.test.ts +++ b/src/renderer/src/blocks/loadBlocks.test.ts @@ -4,7 +4,7 @@ import { blockDescriptors, groupBlocksByCategory } from './loadBlocks'; describe('loadBlocks', () => { it('loads all built-in block descriptors', () => { - expect(blockDescriptors.length).toBe(30); + expect(blockDescriptors.length).toBe(34); expect(blockDescriptors.every(block => block.id && block.category && ((block.steps?.length ?? 0) > 0 || (block.value_sources?.length ?? 0) > 0))).toBe( true ); diff --git a/src/renderer/src/blocks/loadBlocks.ts b/src/renderer/src/blocks/loadBlocks.ts index 7fd81bd..647d4e7 100644 --- a/src/renderer/src/blocks/loadBlocks.ts +++ b/src/renderer/src/blocks/loadBlocks.ts @@ -23,6 +23,7 @@ const CATEGORY_ORDER = [ 'Storage', 'Reports', 'Processes', + 'From modules', 'Methods' ]; diff --git a/src/renderer/src/blocks/promiseTypeIcons.tsx b/src/renderer/src/blocks/promiseTypeIcons.tsx index 80ea77f..87f6999 100644 --- a/src/renderer/src/blocks/promiseTypeIcons.tsx +++ b/src/renderer/src/blocks/promiseTypeIcons.tsx @@ -1,11 +1,15 @@ import type { SvgIconComponent } from '@mui/icons-material'; import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined'; import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined'; +import CloudSyncOutlinedIcon from '@mui/icons-material/CloudSyncOutlined'; import DataObjectIcon from '@mui/icons-material/DataObject'; import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; +import ForkRightIcon from '@mui/icons-material/ForkRight'; +import HttpIcon from '@mui/icons-material/Http'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import MemoryOutlinedIcon from '@mui/icons-material/MemoryOutlined'; import PersonOutlineIcon from '@mui/icons-material/PersonOutlineOutlined'; +import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay'; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import StorageOutlinedIcon from '@mui/icons-material/StorageOutlined'; import TerminalIcon from '@mui/icons-material/Terminal'; @@ -25,5 +29,10 @@ export const PROMISE_TYPE_ICONS: Record = { reports: ArticleOutlinedIcon, commands: TerminalIcon, processes: MemoryOutlinedIcon, - methods: AccountTreeOutlinedIcon + methods: AccountTreeOutlinedIcon, + // Custom promise types, from CFEngine Build modules. + git: ForkRightIcon, + systemd: CloudSyncOutlinedIcon, + http: HttpIcon, + ansible: PlaylistPlayIcon }; diff --git a/src/renderer/src/blocks/types.ts b/src/renderer/src/blocks/types.ts index b1fbe48..8ef1e0d 100644 --- a/src/renderer/src/blocks/types.ts +++ b/src/renderer/src/blocks/types.ts @@ -104,7 +104,23 @@ export type Expression = export interface BlockStep { attributes?: Record; - promise_type: 'vars' | 'classes' | 'users' | 'files' | 'packages' | 'methods' | 'processes' | 'services' | 'commands' | 'storage' | 'reports'; + // git, systemd, http, ansible: custom promise types, from the descriptor's requires_module. + promise_type: + | 'vars' + | 'classes' + | 'users' + | 'files' + | 'packages' + | 'methods' + | 'processes' + | 'services' + | 'commands' + | 'storage' + | 'reports' + | 'git' + | 'systemd' + | 'http' + | 'ansible'; promiser: Template; } @@ -179,6 +195,8 @@ export interface BlockDescriptor { // Which step's outcome drives arrows. outcome_step?: number; parameters?: BlockParameter[]; + // The CFEngine Build module its custom promise type comes from: added to the project with the block. + requires_module?: string; schema_version: 1; steps?: BlockStep[]; value_source_label?: string; diff --git a/src/renderer/src/components/modules/ModulePage.tsx b/src/renderer/src/components/modules/ModulePage.tsx index 6f1051b..61f6210 100644 --- a/src/renderer/src/components/modules/ModulePage.tsx +++ b/src/renderer/src/components/modules/ModulePage.tsx @@ -58,6 +58,8 @@ interface ModulePageProps { onRemove: () => void; // The Input tab first (Edit input). openInput?: boolean; + // The blocks using its promise type: it stays while they're there. + usedBy: string[]; web: ModuleDetailsState; // The dependencies adding it would bring that the project doesn't have yet. wouldAdd: string[]; @@ -98,6 +100,7 @@ export function ModulePage({ onOpenModule, onRemove, openInput, + usedBy, web, wouldAdd }: ModulePageProps) { @@ -132,7 +135,7 @@ export function ModulePage({ {added ? ( !added.module.addedBy && ( - ) @@ -156,6 +159,7 @@ export function ModulePage({ {added?.module.addedBy && Added for {added.module.addedBy}: it’s removed with it, unless another module needs it.} {!added && wouldAdd.length > 0 && Adding it also adds {wouldAdd.join(', ')}.} + {usedBy.length > 0 && Used by {usedBy.join(', ')}: delete those blocks to remove it.} {module.kind === 'promise-type' && Adds a promise type for your own policy. Hosts need python3 to run it.} void; onSelect: () => void; selected: boolean; + // The blocks needing it (its promise type): it can't be removed while they're there. + usedBy?: string[]; } -function GearMenu({ addedFor, module, onEditInput, onRemove }: Pick) { +function GearMenu({ addedFor, module, onEditInput, onRemove, usedBy }: Pick) { const [anchor, setAnchor] = useState(null); const source = sourceUrl(module); const close = () => setAnchor(null); @@ -88,9 +90,9 @@ function GearMenu({ addedFor, module, onEditInput, onRemove }: Pick )} - {addedFor ? ( + {addedFor || usedBy?.length ? ( - + ) : ( {module.version && {module.version}} {inProject ? ( - + ) : added ? ( Added diff --git a/src/renderer/src/components/modules/ModulesView.tsx b/src/renderer/src/components/modules/ModulesView.tsx index 6cecb95..095bce8 100644 --- a/src/renderer/src/components/modules/ModulesView.tsx +++ b/src/renderer/src/components/modules/ModulesView.tsx @@ -68,10 +68,11 @@ interface ModulesViewProps { onSelect: (name: string) => void; projectModules: ProjectModule[]; selectedName: string | null; + usedBy: (name: string) => string[]; } /** The CFEngine Build modules: the project's, then the whole index, searched and filtered by tag. */ -export function ModulesView({ catalog, isNeedingInput, onAdd, onEditInput, onRemove, onSelect, projectModules, selectedName }: ModulesViewProps) { +export function ModulesView({ catalog, isNeedingInput, onAdd, onEditInput, onRemove, onSelect, projectModules, selectedName, usedBy }: ModulesViewProps) { const [filter, setFilter] = useState({ search: '', supportedOnly: false, tag: null }); const [showLibraries, setShowLibraries] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); @@ -103,7 +104,8 @@ export function ModulesView({ catalog, isNeedingInput, onAdd, onEditInput, onRem onSelect: () => onSelect(module.name), onAdd: () => onAdd(module.name), onEditInput: () => onEditInput(module.name), - onRemove: () => onRemove(module.name) + onRemove: () => onRemove(module.name), + usedBy: usedBy(module.name) }); return ( diff --git a/src/renderer/src/mcp/canvasTools.ts b/src/renderer/src/mcp/canvasTools.ts index 6b0a004..5107e58 100644 --- a/src/renderer/src/mcp/canvasTools.ts +++ b/src/renderer/src/mcp/canvasTools.ts @@ -131,7 +131,10 @@ function blockType(blockTypeId: string) { takesArrows: isSequenced(descriptor), parameters: (descriptor.parameters ?? []).map(parameter), valueSources: descriptor.value_sources?.map(source => ({ id: source.id, label: source.label, parameters: source.parameters.map(parameter) })), - entries: descriptor.entries ? 'Holds a list of entries (one variable or class each): see list_value_sources, add_entry and update_entry.' : undefined + entries: descriptor.entries ? 'Holds a list of entries (one variable or class each): see list_value_sources, add_entry and update_entry.' : undefined, + requiresModule: descriptor.requires_module + ? `Its promise type comes from the CFEngine Build module ${descriptor.requires_module}: adding the block adds the module to the project (installed on save; hosts need python3).` + : undefined }; } @@ -459,7 +462,8 @@ function tidyFile(env: CanvasEnv, input: Input) { export const CANVAS_TOOLS: Record = { get_project_overview: env => projectOverview(env.getState(), canvasOf(env)), get_file: (env, input) => fileDetail(env.getState(), str(input, 'fileId')), - list_block_types: () => blockDescriptors.map(({ id, name, category, description }) => ({ id, name, category, description })), + list_block_types: () => + blockDescriptors.map(({ id, name, category, description, requires_module }) => ({ id, name, category, description, requiresModule: requires_module })), get_block_type: (_env, input) => blockType(str(input, 'blockType')), get_generated_policy: (env, input) => generatedPolicy(env.getState(), optionalStr(input, 'fileId')), get_warnings: env => warnings(env.getState()), diff --git a/src/renderer/src/pages/SideBar.tsx b/src/renderer/src/pages/SideBar.tsx index d83e796..7451f6a 100644 --- a/src/renderer/src/pages/SideBar.tsx +++ b/src/renderer/src/pages/SideBar.tsx @@ -33,6 +33,7 @@ export function SideBar({ modules, onResize, onResizeEnd, policy, view, widthFra onEditInput={modules.openInput} onAdd={modules.add} onRemove={modules.remove} + usedBy={modules.usedBy} /> ) : ( policy diff --git a/src/renderer/src/pages/useProjectModules.ts b/src/renderer/src/pages/useProjectModules.ts index 9892868..889d314 100644 --- a/src/renderer/src/pages/useProjectModules.ts +++ b/src/renderer/src/pages/useProjectModules.ts @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { blockDescriptorsById } from '../blocks/loadBlocks'; import { modulesToAdd, modulesToRemove } from '../modules/catalog'; import { needsInput } from '../modules/moduleInput'; import { useModuleCatalog } from '../modules/useModuleCatalog'; @@ -14,6 +15,9 @@ import { type ModuleInputResponse, moduleInputChanged, modulesAdded, modulesRemo export function useProjectModules(viewOpen: boolean, backLabel: string) { const dispatch = useAppDispatch(); const projectModules = useAppSelector(state => state.modules); + const canvas = useAppSelector(state => state.canvas); + // The blocks whose promise type a module provides, by label. + const usedBy = (name: string) => canvas.filter(block => blockDescriptorsById.get(block.blockId)?.requires_module === name).map(block => block.label); const [openName, setOpenName] = useState(null); const [openInput, setOpenInput] = useState(false); const catalog = useModuleCatalog(viewOpen || openName !== null); @@ -48,6 +52,7 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { .map(module => module.name), onAdd: () => add(open.name), onRemove: () => remove(open.name), + usedBy: usedBy(open.name), onBack: close, onInputChange: (variable: string, response: ModuleInputResponse) => dispatch(moduleInputChanged({ name: open.name, variable, response })), onOpenModule: (name: string) => openPage(name), @@ -65,6 +70,7 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { openName, page, projectModules, - remove + remove, + usedBy }; } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 6186717..49f1ee6 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -2,6 +2,7 @@ import { type TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { blockDescriptorsById } from '../blocks/loadBlocks'; import canvasReducer from './canvasSlice'; import clipboardReducer from './clipboardSlice'; import derivedNodesReducer from './derivedNodesSlice'; @@ -13,19 +14,33 @@ import modulesReducer from './modulesSlice'; import projectReducer, { projectClosed } from './projectSlice'; import testEnvironmentsReducer from './testEnvironmentsSlice'; -const appReducer = withHistory( - combineReducers({ - canvas: canvasReducer, - clipboard: clipboardReducer, - derivedNodes: derivedNodesReducer, - edges: edgesReducer, - files: filesReducer, - groups: groupsReducer, - modules: modulesReducer, - project: projectReducer, - testEnvironments: testEnvironmentsReducer - }) -); +const contentReducer = combineReducers({ + canvas: canvasReducer, + clipboard: clipboardReducer, + derivedNodes: derivedNodesReducer, + edges: edgesReducer, + files: filesReducer, + groups: groupsReducer, + modules: modulesReducer, + project: projectReducer, + testEnvironments: testEnvironmentsReducer +}); + +/** + * A block whose promise type comes from a CFEngine Build module (Git Checkout…) brings the module + * into the project, in the same step. Deleting the block keeps it. + */ +const withRequiredModules: typeof contentReducer = (state, action) => { + const next = contentReducer(state, action); + if (next.canvas === state?.canvas) return next; + const present = new Set(next.modules.map(module => module.name)); + const missing = [...new Set(next.canvas.map(block => blockDescriptorsById.get(block.blockId)?.requires_module))].filter( + (name): name is string => name !== undefined && !present.has(name) + ); + return missing.length ? { ...next, modules: [...next.modules, ...missing.map(name => ({ name }))] } : next; +}; + +const appReducer = withHistory(withRequiredModules); // Closing the project starts over: nothing of it stays (or can be undone back into view). const rootReducer: typeof appReducer = (state, action) => appReducer(projectClosed.match(action) ? undefined : state, action); diff --git a/src/renderer/src/store/modulesSlice/index.test.ts b/src/renderer/src/store/modulesSlice/index.test.ts new file mode 100644 index 0000000..c02507c --- /dev/null +++ b/src/renderer/src/store/modulesSlice/index.test.ts @@ -0,0 +1,24 @@ +import { blockRemoved } from '../canvasSlice'; +import { undone } from '../history'; +import { addBlock, addFile, makeStore } from '../test/storeTestUtils'; + +describe('modules a block needs', () => { + it('come with the block in one step, and stay when it goes', () => { + const store = makeStore(); + const fileId = addFile(store); + const instanceId = addBlock(store, fileId, { blockId: 'git-checkout', params: { destination: '/srv/app' } }); + expect(store.getState().modules).toEqual([{ name: 'promise-type-git' }]); + + addBlock(store, fileId, { blockId: 'git-checkout' }); + expect(store.getState().modules).toHaveLength(1); + + store.dispatch(blockRemoved({ instanceId })); + expect(store.getState().modules).toEqual([{ name: 'promise-type-git' }]); + + store.dispatch(undone()); + store.dispatch(undone()); + store.dispatch(undone()); + expect(store.getState().canvas).toEqual([]); + expect(store.getState().modules).toEqual([]); + }); +}); From e8e7abe33d349c51a4e36ce31f970a6dbe8f3bfb Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 17:13:31 +0300 Subject: [PATCH 04/23] Added a Custom Promise block, for any CFEngine Build module's promise type It takes the promise type, the promiser and name = value attribute rows, each compiled to a string or a list of quoted strings, and adds the promise type's module to the project like the other module blocks. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/custom-promise.json | 94 ++++++++++++++++++ blocks/schemas/block-descriptor.v1.json | 49 ++++++---- python/cfpb_compiler.py | 48 +++++++++- python/tests/snapshots/custom-promise.cf | 95 +++++++++++++++++++ python/tests/test_blocks_contract.py | 3 + python/tests/test_compiler.py | 25 +++++ python/tests/test_compiler_all_blocks.py | 3 +- python/tests/test_compiler_block_snapshots.py | 5 + python/tests/test_compiler_block_variants.py | 6 ++ src/main/mcpTools/guide.ts | 1 + src/renderer/src/blocks/loadBlocks.test.ts | 2 +- src/renderer/src/blocks/promiseTypeIcons.tsx | 4 +- src/renderer/src/blocks/requiredModule.ts | 12 +++ src/renderer/src/blocks/resolveBlockShape.ts | 3 +- src/renderer/src/blocks/types.ts | 6 +- src/renderer/src/pages/useProjectModules.ts | 4 +- src/renderer/src/store/index.ts | 6 +- .../src/store/modulesSlice/index.test.ts | 9 ++ 18 files changed, 345 insertions(+), 30 deletions(-) create mode 100644 blocks/custom-promise.json create mode 100644 python/tests/snapshots/custom-promise.cf create mode 100644 src/renderer/src/blocks/requiredModule.ts diff --git a/blocks/custom-promise.json b/blocks/custom-promise.json new file mode 100644 index 0000000..560a5fd --- /dev/null +++ b/blocks/custom-promise.json @@ -0,0 +1,94 @@ +{ + "$schema": "./schemas/block-descriptor.v1.json", + "schema_version": 1, + "id": "custom-promise", + "version": 1, + "name": "Custom Promise", + "category": "From modules", + "description": "A promise of a CFEngine Build module's promise type, e.g. json or groups.", + "compile_target": "own_bundle", + "requires_module": "promise-type-{{promise_type}}", + "steps": [ + { + "promise_type": "{{promise_type}}", + "promiser": "{{promiser}}", + "attribute_rows": "attributes", + "attributes": {} + } + ], + "parameters": [ + { + "name": "promise_type", + "label": "Promise type", + "type": "string", + "required": true, + "default": "json", + "options": [ + { + "value": "appstreams", + "label": "AppStream modules" + }, + { + "value": "assert", + "label": "assert (for test policy)" + }, + { + "value": "docker_compose", + "label": "docker_compose (Docker Compose)" + }, + { + "value": "groups", + "label": "groups (local groups)" + }, + { + "value": "json", + "label": "json (edit JSON files)" + }, + { + "value": "sshd", + "label": "sshd (sshd configuration)" + }, + { + "value": "symlinks", + "label": "symlinks" + }, + { + "value": "yaml", + "label": "yaml (edit YAML files)" + }, + { + "value": "git", + "label": "git" + }, + { + "value": "systemd", + "label": "systemd" + }, + { + "value": "http", + "label": "http" + }, + { + "value": "ansible", + "label": "ansible" + } + ], + "help": "Adds its module (promise-type-…) to the project. Its README on the module's page lists the attributes." + }, + { + "name": "promiser", + "label": "Promiser", + "type": "string", + "required": true, + "help": "What the promise is about, e.g. a file path or a group name." + }, + { + "name": "attributes", + "label": "Attributes", + "type": "text", + "required": false, + "default": "", + "help": "One per line, name = value. A list: name = { \"a\", \"b\" }." + } + ] +} diff --git a/blocks/schemas/block-descriptor.v1.json b/blocks/schemas/block-descriptor.v1.json index 0661526..d07f449 100644 --- a/blocks/schemas/block-descriptor.v1.json +++ b/blocks/schemas/block-descriptor.v1.json @@ -214,24 +214,37 @@ ], "properties": { "promise_type": { - "enum": [ - "vars", - "classes", - "users", - "files", - "packages", - "methods", - "processes", - "services", - "commands", - "storage", - "reports", - "git", - "systemd", - "http", - "ansible" - ], - "description": "git, systemd, http and ansible are custom promise types: the block's requires_module provides them." + "anyOf": [ + { + "enum": [ + "vars", + "classes", + "users", + "files", + "packages", + "methods", + "processes", + "services", + "commands", + "storage", + "reports", + "git", + "systemd", + "http", + "ansible" + ], + "description": "git, systemd, http and ansible are custom promise types: the block's requires_module provides them." + }, + { + "type": "string", + "pattern": "^\\{\\{[a-z_]+\\}\\}$", + "description": "A parameter holding the custom promise type's name (the Custom promise block)." + } + ] + }, + "attribute_rows": { + "type": "string", + "description": "A text parameter of `name = value` lines, each an attribute: a string, or a list of quoted strings ({ \"a\", \"b\" })." }, "promiser": { "$ref": "common.v1.json#/$defs/template", diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 2066824..0a22387 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -399,6 +399,50 @@ def unquote(string: str) -> str: return re.sub(r"\\([\\\"'])", r"\1", string[1:-1]) +PROMISE_TYPE = re.compile(r"^[a-z][a-z0-9_]*$") +ATTRIBUTE_ROW = re.compile(r"^\s*([a-z][a-z0-9_]*)\s*=\s*(.*?)\s*$") +# A list of quoted strings, written as in CFEngine: { "a", "b" }. +QUOTED_LIST = re.compile(r'^\{\s*(?:"(?:[^"\\]|\\.)*"\s*(?:,\s*"(?:[^"\\]|\\.)*"\s*)*)?\}$') +# Handled by the agent and set by the block itself (condition, arrows, How often, note). +AGENT_ATTRIBUTES = { + "if", + "unless", + "ifvarclass", + "classes", + "action", + "comment", + "depends_on", + "handle", + "meta", + "with", +} + + +def promise_type_of(step: dict, params: dict) -> str: + """A step's promise type; a templated one (the Custom promise block) is the picked custom type.""" + kind = step["promise_type"] + if not kind.startswith("{{"): + return kind + picked = params.get(kind.strip("{}"), "").strip() + if not PROMISE_TYPE.match(picked): + raise Skip("no promise type picked" if not picked else f"{picked!r} isn't a promise type name") + return picked + + +def attribute_rows(text: str) -> list[str]: + """`name = value` lines as attributes: a value is a string, or a list of quoted strings.""" + lines = [] + for row in lines_of(text): + found = ATTRIBUTE_ROW.match(row) + if not found: + raise Skip(f"{row.strip()!r} isn't an attribute: write name = value") + name, value = found.groups() + if name in AGENT_ATTRIBUTES: + raise Skip(f"{name} is set by the block itself (condition, arrows, How often, note)") + lines.append(f"{name} => {value if QUOTED_LIST.match(value) else quote(value)}") + return lines + + def attributes_of(step: dict, ctx: Context) -> list[str]: lines = [] for key, value in step.get("attributes", {}).items(): @@ -1021,6 +1065,8 @@ def block_parts( # The promise iterates as a whole: its attributes see the same list variable. ctx.params = promiser_params attributes = attributes_of(step, ctx) + if "attribute_rows" in step: + attributes += attribute_rows(params.get(step["attribute_rows"], "")) variables += ctx.locals # A computed template is only known at run time: it renders against datastate(). template_bound = any(p.get("mustache") and p["name"] in computed for p in declared) @@ -1028,7 +1074,7 @@ def block_parts( if template_data: variables += template_data attributes.append(f"template_data => @({prefix}template_data)") - return variables, step["promise_type"], quote(promiser), attributes + return variables, promise_type_of(step, params), quote(promiser), attributes def custom_block(self, block: dict, name: str, params: dict[str, str]) -> str: """A Custom CFEngine block's text: its promises as a bundle named after the block, then diff --git a/python/tests/snapshots/custom-promise.cf b/python/tests/snapshots/custom-promise.cf new file mode 100644 index 0000000..4cd180c --- /dev/null +++ b/python/tests/snapshots/custom-promise.cf @@ -0,0 +1,95 @@ +### example + # Case 0 + json: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'appstreams' + # Case 1 + appstreams: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'assert' + # Case 2 + assert: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'docker_compose' + # Case 3 + docker_compose: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'groups' + # Case 4 + groups: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'sshd' + # Case 5 + sshd: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'symlinks' + # Case 6 + symlinks: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'yaml' + # Case 7 + yaml: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'git' + # Case 8 + git: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'systemd' + # Case 9 + systemd: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'http' + # Case 10 + http: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; + +### promise_type = 'ansible' + # Case 11 + ansible: + "/etc/app/config.json" + field => "name", + value => "shop", + list => { "a", "b" }; diff --git a/python/tests/test_blocks_contract.py b/python/tests/test_blocks_contract.py index 483cdc0..c8e136c 100644 --- a/python/tests/test_blocks_contract.py +++ b/python/tests/test_blocks_contract.py @@ -146,6 +146,9 @@ def _check_steps(steps: list[dict], params: list[dict], where: str, problems: li for index, step in enumerate(steps): at = f"{where}/steps/{index}" used.update(PLACEHOLDER.findall(step["promiser"])) + used.update(PLACEHOLDER.findall(step["promise_type"])) + if "attribute_rows" in step: + used.add(step["attribute_rows"]) for attribute, value in step.get("attributes", {}).items(): used.update(_walk(value, top_level=True, in_decorator=False, problems=problems, where=f"{at}/{attribute}")) if ( diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 7aab6dc..8a9f5c1 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -703,3 +703,28 @@ def test_the_source_map_points_at_each_block_and_group(): assert lines[call[0] - 1] == " # Group: Configure nginx" assert lines[bundle[0] : bundle[1]][0] == "bundle agent configure_nginx" assert lines[bundle[1] - 1] == "}" + + +def _custom_promise(promise_type: str, attributes: str) -> str: + block = { + "instanceId": "p", + "blockId": "custom-promise", + "label": "Edit config", + "params": {"promise_type": promise_type, "promiser": "/etc/app.json", "attributes": attributes}, + } + project = { + "files": [ + {"id": "f", "name": "Site", "namespace": "site", "path": "./site.cf", "blocks": [block], "order": ["p"]} + ] + } + return compile_project(project)["./site.cf"] + + +def test_a_custom_promise_takes_attribute_rows_as_strings_or_quoted_lists(): + policy = _custom_promise("json", 'field = name\nlist = { "a", "b" }\nodd = "}; files: "/x" delete => tidy; #') + assert ' json:\n "/etc/app.json"\n field => "name",\n list => { "a", "b" },' in policy + # Anything but a list of quoted strings is one string, so it can't break out of the promise. + assert 'odd => "\\"}; files: \\"/x\\" delete => tidy; #"' in policy + assert "Skipped" in _custom_promise("json", "not a row") + assert "set by the block itself" in _custom_promise("json", "classes = x") + assert "isn't a promise type name" in _custom_promise("files: x", "") diff --git a/python/tests/test_compiler_all_blocks.py b/python/tests/test_compiler_all_blocks.py index 7b9bc7c..ae0c9f9 100644 --- a/python/tests/test_compiler_all_blocks.py +++ b/python/tests/test_compiler_all_blocks.py @@ -16,6 +16,7 @@ # Sample values by parameter name; anything else gets its default, first option, or a placeholder. SAMPLES = { + "attributes": "field = name\nvalue = shop", "path": "/etc/hosts", "destination": "/tmp/cfpb-test/out", "source": "/tmp/cfpb-test/in", @@ -199,7 +200,7 @@ def test_every_block_source_and_decorator_passes_cf_promises_and_lint(tmp_path: # Custom promise types are declared by their modules' enable.cf (cfbs appends it to services/init.cf). declared = "".join( f'promise agent {kind} {{ path => "/var/cfengine/modules/promises/{kind}.py"; interpreter => "/usr/bin/python3"; }}\n' - for kind in ("git", "systemd", "http", "ansible") + for kind in ("git", "systemd", "http", "ansible", "json") ) (tmp_path / "promises.cf").write_text( 'body common control { inputs => { "stdlib.cf", "all.cf" }; bundlesequence => { "all:main" }; }\n' diff --git a/python/tests/test_compiler_block_snapshots.py b/python/tests/test_compiler_block_snapshots.py index cec5a8c..0862ef1 100644 --- a/python/tests/test_compiler_block_snapshots.py +++ b/python/tests/test_compiler_block_snapshots.py @@ -23,6 +23,11 @@ "comment-lines": {"path": "/etc/nginx/nginx.conf", "pattern": "listen 8080.*"}, "copy-directory": {"destination": "/opt/app/static", "source": "/srv/build/static"}, "copy-file": {"destination": "/etc/motd", "source": "/srv/files/motd", "server": ""}, + "custom-promise": { + "promise_type": "json", + "promiser": "/etc/app/config.json", + "attributes": 'field = name\nvalue = shop\nlist = { "a", "b" }', + }, "custom-cfengine": { "promises": 'files:\n "/var/www/site"\n copy_from => mirror("/srv/site"),\n depth_search => default:recurse("inf");', "definitions": 'body copy_from mirror(from)\n{\n source => "$(from)";\n purge => "true";\n}', diff --git a/python/tests/test_compiler_block_variants.py b/python/tests/test_compiler_block_variants.py index 673718f..6ac98b5 100644 --- a/python/tests/test_compiler_block_variants.py +++ b/python/tests/test_compiler_block_variants.py @@ -26,6 +26,12 @@ 'commands:\n "/bin/true"\n contain => default:in_shell;', ], "definitions": [""], + # Custom Promise's attribute rows: tricky values, and a list. + "attributes": [ + *(f"value = {text}" for text in ['say "hi"', "C:\\temp\\", "a\\b and $(sys.fqhost)"]), + 'names = { "a", "say \\"hi\\"" }', + "", + ], } diff --git a/src/main/mcpTools/guide.ts b/src/main/mcpTools/guide.ts index b624d0f..4a21ba7 100644 --- a/src/main/mcpTools/guide.ts +++ b/src/main/mcpTools/guide.ts @@ -59,6 +59,7 @@ const blocks = `# Blocks, arrows and groups ## Blocks from CFEngine Build modules - Git Checkout (git-checkout), Systemd Unit (systemd-unit), HTTP Request (http-request) and Run Ansible Playbook (ansible-playbook) are custom promise types (git:, systemd:, http:, ansible:). Adding one adds its module (promise-type-git…) to the project; saving installs it with cfbs (needs the internet once). Hosts need python3, and git or Ansible for those blocks. +- Custom Promise (custom-promise) is any other module promise type (json, yaml, groups, sshd, symlinks, docker_compose, appstreams, assert): pick promise_type, set the promiser and attributes as \`name = value\` lines (a list: \`name = { "a", "b" }\`); its module is added the same way. Its README (the module's page) lists the attributes. - Use them rather than Run Command for cloning repositories, systemd units, HTTP requests and playbooks: they report kept/repaired properly, so arrows and How often work. ## Custom CFEngine (hand-written promises) diff --git a/src/renderer/src/blocks/loadBlocks.test.ts b/src/renderer/src/blocks/loadBlocks.test.ts index 7dc9232..7f25d10 100644 --- a/src/renderer/src/blocks/loadBlocks.test.ts +++ b/src/renderer/src/blocks/loadBlocks.test.ts @@ -4,7 +4,7 @@ import { blockDescriptors, groupBlocksByCategory } from './loadBlocks'; describe('loadBlocks', () => { it('loads all built-in block descriptors', () => { - expect(blockDescriptors.length).toBe(34); + expect(blockDescriptors.length).toBe(35); expect(blockDescriptors.every(block => block.id && block.category && ((block.steps?.length ?? 0) > 0 || (block.value_sources?.length ?? 0) > 0))).toBe( true ); diff --git a/src/renderer/src/blocks/promiseTypeIcons.tsx b/src/renderer/src/blocks/promiseTypeIcons.tsx index 87f6999..c71934b 100644 --- a/src/renderer/src/blocks/promiseTypeIcons.tsx +++ b/src/renderer/src/blocks/promiseTypeIcons.tsx @@ -4,6 +4,7 @@ import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined'; import CloudSyncOutlinedIcon from '@mui/icons-material/CloudSyncOutlined'; import DataObjectIcon from '@mui/icons-material/DataObject'; import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; +import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; import ForkRightIcon from '@mui/icons-material/ForkRight'; import HttpIcon from '@mui/icons-material/Http'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; @@ -34,5 +35,6 @@ export const PROMISE_TYPE_ICONS: Record = { git: ForkRightIcon, systemd: CloudSyncOutlinedIcon, http: HttpIcon, - ansible: PlaylistPlayIcon + ansible: PlaylistPlayIcon, + promise: ExtensionOutlinedIcon }; diff --git a/src/renderer/src/blocks/requiredModule.ts b/src/renderer/src/blocks/requiredModule.ts new file mode 100644 index 0000000..5969ad1 --- /dev/null +++ b/src/renderer/src/blocks/requiredModule.ts @@ -0,0 +1,12 @@ +import type { BlockInstance } from '../store/canvasSlice/types'; +import { blockDescriptorsById } from './loadBlocks'; + +/** + * The CFEngine Build module a block's promise type comes from. "promise-type-{{promise_type}}" + * (Custom Promise) names it from the picked type: docker_compose → promise-type-docker-compose. + */ +export function requiredModule(block: Pick): string | undefined { + const template = blockDescriptorsById.get(block.blockId)?.requires_module; + const name = template?.replace(/\{\{(\w+)\}\}/g, (_match, param: string) => (block.params[param] ?? '').trim().replace(/_/g, '-')); + return name && !name.endsWith('-') ? name : undefined; +} diff --git a/src/renderer/src/blocks/resolveBlockShape.ts b/src/renderer/src/blocks/resolveBlockShape.ts index 22d0247..be3b71c 100644 --- a/src/renderer/src/blocks/resolveBlockShape.ts +++ b/src/renderer/src/blocks/resolveBlockShape.ts @@ -16,6 +16,7 @@ export function resolveBlockShape(descriptor: BlockDescriptor, valueSourceId: st } export function primaryPromiseType(descriptor: BlockDescriptor): string | undefined { - if (descriptor.steps) return descriptor.steps[0]?.promise_type; + // A promise type picked in a parameter (Custom Promise): any custom promise. + if (descriptor.steps) return descriptor.steps[0]?.promise_type.startsWith('{{') ? 'promise' : descriptor.steps[0]?.promise_type; return descriptor.value_sources?.[0]?.steps[0]?.promise_type; } diff --git a/src/renderer/src/blocks/types.ts b/src/renderer/src/blocks/types.ts index 8ef1e0d..bbc9720 100644 --- a/src/renderer/src/blocks/types.ts +++ b/src/renderer/src/blocks/types.ts @@ -103,6 +103,8 @@ export type Expression = | TemplateFileExpression; export interface BlockStep { + // A text parameter of `name = value` lines, each an attribute (Custom Promise). + attribute_rows?: string; attributes?: Record; // git, systemd, http, ansible: custom promise types, from the descriptor's requires_module. promise_type: @@ -120,7 +122,9 @@ export interface BlockStep { | 'git' | 'systemd' | 'http' - | 'ansible'; + | 'ansible' + // A parameter holding a custom promise type's name (Custom Promise). + | `{{${string}}}`; promiser: Template; } diff --git a/src/renderer/src/pages/useProjectModules.ts b/src/renderer/src/pages/useProjectModules.ts index 889d314..4e0d237 100644 --- a/src/renderer/src/pages/useProjectModules.ts +++ b/src/renderer/src/pages/useProjectModules.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { blockDescriptorsById } from '../blocks/loadBlocks'; +import { requiredModule } from '../blocks/requiredModule'; import { modulesToAdd, modulesToRemove } from '../modules/catalog'; import { needsInput } from '../modules/moduleInput'; import { useModuleCatalog } from '../modules/useModuleCatalog'; @@ -17,7 +17,7 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { const projectModules = useAppSelector(state => state.modules); const canvas = useAppSelector(state => state.canvas); // The blocks whose promise type a module provides, by label. - const usedBy = (name: string) => canvas.filter(block => blockDescriptorsById.get(block.blockId)?.requires_module === name).map(block => block.label); + const usedBy = (name: string) => canvas.filter(block => requiredModule(block) === name).map(block => block.label); const [openName, setOpenName] = useState(null); const [openInput, setOpenInput] = useState(false); const catalog = useModuleCatalog(viewOpen || openName !== null); diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 49f1ee6..c89211f 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -2,7 +2,7 @@ import { type TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux import { combineReducers, configureStore } from '@reduxjs/toolkit'; -import { blockDescriptorsById } from '../blocks/loadBlocks'; +import { requiredModule } from '../blocks/requiredModule'; import canvasReducer from './canvasSlice'; import clipboardReducer from './clipboardSlice'; import derivedNodesReducer from './derivedNodesSlice'; @@ -34,9 +34,7 @@ const withRequiredModules: typeof contentReducer = (state, action) => { const next = contentReducer(state, action); if (next.canvas === state?.canvas) return next; const present = new Set(next.modules.map(module => module.name)); - const missing = [...new Set(next.canvas.map(block => blockDescriptorsById.get(block.blockId)?.requires_module))].filter( - (name): name is string => name !== undefined && !present.has(name) - ); + const missing = [...new Set(next.canvas.map(requiredModule))].filter((name): name is string => name !== undefined && !present.has(name)); return missing.length ? { ...next, modules: [...next.modules, ...missing.map(name => ({ name }))] } : next; }; diff --git a/src/renderer/src/store/modulesSlice/index.test.ts b/src/renderer/src/store/modulesSlice/index.test.ts index c02507c..dddbe17 100644 --- a/src/renderer/src/store/modulesSlice/index.test.ts +++ b/src/renderer/src/store/modulesSlice/index.test.ts @@ -22,3 +22,12 @@ describe('modules a block needs', () => { expect(store.getState().modules).toEqual([]); }); }); + +describe('a Custom Promise block', () => { + it('brings the module of the promise type it picks', () => { + const store = makeStore(); + const fileId = addFile(store); + addBlock(store, fileId, { blockId: 'custom-promise', params: { promise_type: 'docker_compose', promiser: 'app' } }); + expect(store.getState().modules).toEqual([{ name: 'promise-type-docker-compose' }]); + }); +}); From 75400c35087f20575164a8776f0eea12a9dc2e08 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 17:27:32 +0300 Subject: [PATCH 05/23] A block search that finds nothing points at the modules that match it Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/components/BlockPalette.tsx | 39 +++++++++++++++++-- .../src/components/modules/ModulesView.tsx | 17 +++++++- src/renderer/src/pages/ProjectView.tsx | 7 +++- src/renderer/src/pages/SideBar.tsx | 2 + src/renderer/src/pages/useProjectModules.ts | 9 ++++- 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/BlockPalette.tsx b/src/renderer/src/components/BlockPalette.tsx index 23801ca..b592a35 100644 --- a/src/renderer/src/components/BlockPalette.tsx +++ b/src/renderer/src/components/BlockPalette.tsx @@ -2,7 +2,7 @@ import { memo, useMemo, useState } from 'react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import SearchIcon from '@mui/icons-material/Search'; -import { Box, ButtonBase, InputAdornment, TextField, Typography } from '@mui/material'; +import { Box, ButtonBase, InputAdornment, Link, TextField, Typography } from '@mui/material'; import { useDraggable } from '@dnd-kit/core'; @@ -10,6 +10,7 @@ import { blockDescriptors, groupBlocksByCategory } from '../blocks/loadBlocks'; import { PROMISE_TYPE_ICONS } from '../blocks/promiseTypeIcons'; import { primaryPromiseType } from '../blocks/resolveBlockShape'; import type { BlockDescriptor } from '../blocks/types'; +import { type CatalogModule, filterCatalog } from '../modules/catalog'; // Namespaced so ProjectView's onDragEnd can tell a palette drag (add) apart // from a canvas-card drag (reorder), which uses bare instanceIds. @@ -75,7 +76,34 @@ function CategoryGroup({ category, blocks, onAdd }: { blocks: BlockDescriptor[]; ); } -export const BlockPalette = memo(function BlockPalette({ onAddBlock }: { onAddBlock: (block: BlockDescriptor) => void }) { +interface BlockPaletteProps { + // The CFEngine Build modules, to point at those a search no block matches finds (empty until loaded). + modules?: CatalogModule[]; + onAddBlock: (block: BlockDescriptor) => void; + onBrowseModules?: (search: string) => void; + // Typing in the search: the module list loads. + onSearch?: () => void; +} + +// No block matches: modules may, for what no block covers. +function NoBlocks({ modules = [], onBrowseModules, search }: Pick & { search: string }) { + const count = useMemo(() => filterCatalog(modules, { search, supportedOnly: false, tag: null }).length, [modules, search]); + return ( + + No blocks match “{search}”. + {count > 0 && onBrowseModules && ( + + {count === 1 ? 'One module matches' : `${count} modules match`}.{' '} + onBrowseModules(search)} sx={{ fontSize: 13, verticalAlign: 'baseline' }}> + Browse modules… + + + )} + + ); +} + +export const BlockPalette = memo(function BlockPalette({ modules, onAddBlock, onBrowseModules, onSearch }: BlockPaletteProps) { const [search, setSearch] = useState(''); const groups = useMemo(() => { @@ -94,7 +122,10 @@ export const BlockPalette = memo(function BlockPalette({ onAddBlock }: { onAddBl
setSearch(event.target.value)} + onChange={event => { + setSearch(event.target.value); + onSearch?.(); + }} placeholder="Search blocks…" variant="standard" fullWidth @@ -112,7 +143,7 @@ export const BlockPalette = memo(function BlockPalette({ onAddBlock }: { onAddBl
- {groups.size === 0 && No blocks match “{search}”.} + {groups.size === 0 && } {[...groups.entries()].map(([category, blocks]) => ( ))} diff --git a/src/renderer/src/components/modules/ModulesView.tsx b/src/renderer/src/components/modules/ModulesView.tsx index 095bce8..8088fb7 100644 --- a/src/renderer/src/components/modules/ModulesView.tsx +++ b/src/renderer/src/components/modules/ModulesView.tsx @@ -61,6 +61,8 @@ function Section({ children, count, title }: { children: ReactNode; count: numbe interface ModulesViewProps { catalog: ModuleCatalog; + // What the search starts with (Browse modules… from the Blocks palette). + initialSearch?: string; isNeedingInput: (name: string) => boolean; onAdd: (name: string) => void; onEditInput: (name: string) => void; @@ -72,8 +74,19 @@ interface ModulesViewProps { } /** The CFEngine Build modules: the project's, then the whole index, searched and filtered by tag. */ -export function ModulesView({ catalog, isNeedingInput, onAdd, onEditInput, onRemove, onSelect, projectModules, selectedName, usedBy }: ModulesViewProps) { - const [filter, setFilter] = useState({ search: '', supportedOnly: false, tag: null }); +export function ModulesView({ + catalog, + initialSearch = '', + isNeedingInput, + onAdd, + onEditInput, + onRemove, + onSelect, + projectModules, + selectedName, + usedBy +}: ModulesViewProps) { + const [filter, setFilter] = useState({ search: initialSearch, supportedOnly: false, tag: null }); const [showLibraries, setShowLibraries] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index 9b764e1..d479948 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -839,6 +839,11 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti onDragCancel: useStableCallback(() => setDragPreview(null)) }; // The sidebars are memoized: they get stable callbacks, and counts that change only with them. + const wantModules = useStableCallback(modules.want); + const browseModules = useStableCallback((search: string) => { + modules.browse(search); + sideBar.show('modules'); + }); const paletteAddBlock = useStableCallback((descriptor: BlockDescriptor) => { sideBar.toCanvas(); modules.close(); @@ -1074,7 +1079,7 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti policy={ <> - + diff --git a/src/renderer/src/pages/SideBar.tsx b/src/renderer/src/pages/SideBar.tsx index 7451f6a..a0ac6e6 100644 --- a/src/renderer/src/pages/SideBar.tsx +++ b/src/renderer/src/pages/SideBar.tsx @@ -25,6 +25,8 @@ export function SideBar({ modules, onResize, onResizeEnd, policy, view, widthFra {view === 'modules' ? ( canvas.filter(block => requiredModule(block) === name).map(block => block.label); const [openName, setOpenName] = useState(null); const [openInput, setOpenInput] = useState(false); - const catalog = useModuleCatalog(viewOpen || openName !== null); + // The Blocks palette's search asks for the list too, to point at modules that match. + const [wanted, setWanted] = useState(false); + // A search to open the Modules view with (Browse modules…), and a count that remounts it. + const [browsing, setBrowsing] = useState({ search: '', count: 0 }); + const catalog = useModuleCatalog(viewOpen || openName !== null || wanted); const open = openName ? catalog.byName.get(openName) : undefined; const web = useModuleDetails(open); @@ -61,6 +65,9 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { return { add, + browse: (search: string) => setBrowsing(current => ({ search, count: current.count + 1 })), + browsing, + want: () => setWanted(true), catalog, close, isNeedingInput, From 22c5bccf336fb6b0a80517ea5434d19bfe952b60 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 17:35:43 +0300 Subject: [PATCH 06/23] AI agents can search, add, answer and remove CFEngine Build modules Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/mcpTools/guide.ts | 5 + src/main/mcpTools/index.ts | 4 +- src/main/mcpTools/modules.ts | 70 ++++++++ src/main/mcpTools/permissions.ts | 16 +- src/renderer/src/mcp/canvasTools.ts | 2 + src/renderer/src/mcp/describe.ts | 5 + src/renderer/src/mcp/moduleTools.test.ts | 94 +++++++++++ src/renderer/src/mcp/moduleTools.ts | 206 +++++++++++++++++++++++ src/renderer/src/mcp/tools.ts | 3 +- 9 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 src/main/mcpTools/modules.ts create mode 100644 src/renderer/src/mcp/moduleTools.test.ts create mode 100644 src/renderer/src/mcp/moduleTools.ts diff --git a/src/main/mcpTools/guide.ts b/src/main/mcpTools/guide.ts index 4a21ba7..a96b708 100644 --- a/src/main/mcpTools/guide.ts +++ b/src/main/mcpTools/guide.ts @@ -57,6 +57,11 @@ const blocks = `# Blocks, arrows and groups - group_blocks puts blocks into a frame that runs as one step (its own bundle). Arrows into and out of the group attach to its frame; an arrow out of a group reads the group's combined outcome. - Use groups for a set of blocks that belong together ("Configure nginx"), and set_group_condition to gate them all. +## CFEngine Build modules +- build.cfengine.com has ready-made modules: inventory, compliance reports, hardening, configured tasks (delete-files, allow-hosts, clamav…) and promise types. Before building something from blocks, search_modules for it; get_module shows what one does (its README), its input questions and dependencies. +- add_module adds it to the project (a policy set); saving installs it with cfbs. A module with input needs every question answered: set_module_input with text, a list of texts, or rows keyed by the list's fields. get_project_overview lists the project's modules. +- Modules run before the project's own policy, in their build order; they're not blocks and have no arrows. + ## Blocks from CFEngine Build modules - Git Checkout (git-checkout), Systemd Unit (systemd-unit), HTTP Request (http-request) and Run Ansible Playbook (ansible-playbook) are custom promise types (git:, systemd:, http:, ansible:). Adding one adds its module (promise-type-git…) to the project; saving installs it with cfbs (needs the internet once). Hosts need python3, and git or Ansible for those blocks. - Custom Promise (custom-promise) is any other module promise type (json, yaml, groups, sshd, symlinks, docker_compose, appstreams, assert): pick promise_type, set the promiser and attributes as \`name = value\` lines (a list: \`name = { "a", "b" }\`); its module is added the same way. Its README (the module's page) lists the attributes. diff --git a/src/main/mcpTools/index.ts b/src/main/mcpTools/index.ts index 03d327c..f0df08b 100644 --- a/src/main/mcpTools/index.ts +++ b/src/main/mcpTools/index.ts @@ -3,6 +3,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerCanvasTools } from './canvas'; import { registerDefinitionTools } from './definitions'; import { registerGuide } from './guideTools'; +import { registerModuleTools } from './modules'; import { type McpPermissions, gatedServer } from './permissions'; import { registerProjectTools } from './project'; import { type CallWindow, answerWith } from './shared'; @@ -16,7 +17,7 @@ import { registerWorkTools } from './work'; export const MCP_INSTRUCTIONS = `This server is CFEngine Policy Builder, a desktop app where CFEngine policy is built visually: policy files hold blocks (each compiles to promises), arrows order them by outcome (kept, repaired, not_kept), groups run blocks as one step, and conditions gate them. Each policy file is a CFEngine namespace. -Change the project only through these tools: the app generates the .cf files and cfbs.json from its blocks on save, so editing those files directly is lost (or refused) on the next save. Each edit shows on the user's canvas and is one undo step there; nothing is written to disk until the project is saved: save only when the user asks or agrees. Read get_guide (the workflow) before your first change, and the other topics when you need them. Call begin_work first with what you're about to do (the user sees it, and the editor is locked so they don't interfere), and end_work when you're done. If a call comes back "Stopped by the user", stop and ask them. Start with get_project_overview, look a block type up with get_block_type before adding or changing one, and read get_generated_policy to check the result. Use the Custom CFEngine block (hand-written promises) only for what no block covers, and ask the user first.`; +Change the project only through these tools: the app generates the .cf files and cfbs.json from its blocks on save, so editing those files directly is lost (or refused) on the next save. Each edit shows on the user's canvas and is one undo step there; nothing is written to disk until the project is saved: save only when the user asks or agrees. Read get_guide (the workflow) before your first change, and the other topics when you need them. Call begin_work first with what you're about to do (the user sees it, and the editor is locked so they don't interfere), and end_work when you're done. If a call comes back "Stopped by the user", stop and ask them. Start with get_project_overview, look a block type up with get_block_type before adding or changing one, and read get_generated_policy to check the result. Ready-made CFEngine Build modules (search_modules) may already do what's asked. Use the Custom CFEngine block (hand-written promises) only for what no block or module covers, and ask the user first.`; // Only the tools the user allows (permissions.ts). export function registerBuilderTools(mcp: McpServer, call: CallWindow, permissions: McpPermissions) { @@ -27,5 +28,6 @@ export function registerBuilderTools(mcp: McpServer, call: CallWindow, permissio registerProjectTools(server, answer); registerCanvasTools(server, answer); registerDefinitionTools(server, answer); + registerModuleTools(server, answer); registerTestTools(server, answer); } diff --git a/src/main/mcpTools/modules.ts b/src/main/mcpTools/modules.ts new file mode 100644 index 0000000..373dbb7 --- /dev/null +++ b/src/main/mcpTools/modules.ts @@ -0,0 +1,70 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { type Answer, EDIT, READ_ONLY, REMOVE } from './shared'; + +/** CFEngine Build modules (build.cfengine.com) in a policy set (src/renderer/src/mcp/moduleTools.ts answers them). */ + +const name = z.string().describe('A module name from search_modules, e.g. "delete-files"'); + +export function registerModuleTools(server: McpServer, answer: Answer) { + server.registerTool( + 'search_modules', + { + inputSchema: { + query: z.string().optional().describe('Words in the name, description or tags (also old names); omitted: all'), + tag: z.string().optional().describe('Only modules with this tag, e.g. "security", "inventory", "compliance", "promise-type", "library"'), + supportedOnly: z.boolean().optional().describe('Only modules tagged supported') + }, + description: + 'Searches the CFEngine Build index (build.cfengine.com): ready-made modules for inventory, compliance, hardening, configured tasks (delete files, allow hosts…) and promise types (git, systemd, http…). Each: name, kind (policy | configured: takes input | promise-type | library), version, description, tags, experimental, inProject. Check here before building something from blocks.', + annotations: READ_ONLY + }, + answer('search_modules') + ); + server.registerTool( + 'get_module', + { + inputSchema: { name }, + description: + 'One module: kind, version, author, source, dependencies, its input questions (variable, type text | list, a list’s fields; answered or not in the project), its README as text, and in the project: addedFor (a dependency), needsInput, usedBy (blocks using its promise type).', + annotations: READ_ONLY + }, + answer('get_module') + ); + server.registerTool( + 'add_module', + { + inputSchema: { name }, + description: + 'Adds a module (and its dependencies) to the project, a policy set; one undo step. Saving installs it with cfbs (needs the internet once). Modules run before the project’s own policy. A module with input asks for it: answer with set_module_input.', + annotations: EDIT + }, + answer('add_module') + ); + server.registerTool( + 'set_module_input', + { + inputSchema: { + name, + variable: z.string().describe('The question’s variable, from get_module'), + response: z + .union([z.string(), z.array(z.string()), z.array(z.record(z.string(), z.string()))]) + .describe('Text; for a list, its entries: texts, or objects keyed by the list’s fields (e.g. [{"path": "/etc/x", "why": "…"}]). Replaces the answer.') + }, + description: 'Answers one of a module’s input questions (saved as /input.json, which cfbs turns into augments). One undo step.', + annotations: EDIT + }, + answer('set_module_input') + ); + server.registerTool( + 'remove_module', + { + inputSchema: { name }, + description: + 'Removes a module from the project, with the dependencies nothing else needs. Refused for a dependency, or while blocks use its promise type. Saving uninstalls it.', + annotations: REMOVE + }, + answer('remove_module') + ); +} diff --git a/src/main/mcpTools/permissions.ts b/src/main/mcpTools/permissions.ts index 3562ead..fc788e3 100644 --- a/src/main/mcpTools/permissions.ts +++ b/src/main/mcpTools/permissions.ts @@ -39,6 +39,8 @@ const GROUP_OF: Record = { list_test_platforms: 'read', get_test_environments: 'read', get_test_results: 'read', + search_modules: 'read', + get_module: 'read', add_block: 'edit', add_blocks: 'edit', @@ -59,6 +61,9 @@ const GROUP_OF: Record = { remove_transformer: 'edit', bind_parameter: 'edit', unbind_parameter: 'edit', + add_module: 'edit', + set_module_input: 'edit', + remove_module: 'edit', add_file: 'files', rename_file: 'files', @@ -78,7 +83,16 @@ const GROUP_OF: Record = { }; // Tools that remove project content, whatever their group. -const DELETES = new Set(['remove_block', 'disconnect', 'remove_entry', 'remove_transformer', 'unbind_parameter', 'remove_file', 'remove_test_environment']); +const DELETES = new Set([ + 'remove_block', + 'disconnect', + 'remove_entry', + 'remove_transformer', + 'unbind_parameter', + 'remove_file', + 'remove_test_environment', + 'remove_module' +]); export const knownTool = (name: string) => name in GROUP_OF; diff --git a/src/renderer/src/mcp/canvasTools.ts b/src/renderer/src/mcp/canvasTools.ts index 5107e58..df59dfa 100644 --- a/src/renderer/src/mcp/canvasTools.ts +++ b/src/renderer/src/mcp/canvasTools.ts @@ -64,6 +64,8 @@ function projectOverview(state: RootState, env: CanvasEnv) { const selected = state.canvas.find(block => block.instanceId === env.selectedInstanceId); return { project: state.project?.name ?? null, + // CFEngine Build modules, in build order (get_module for one). + modules: state.modules.map(module => ({ name: module.name, addedFor: module.addedBy })), files: files.map(file => { const counts: Record = {}; for (const block of state.canvas.filter(item => item.fileId === file.id)) { diff --git a/src/renderer/src/mcp/describe.ts b/src/renderer/src/mcp/describe.ts index 8b8dbca..4b615de 100644 --- a/src/renderer/src/mcp/describe.ts +++ b/src/renderer/src/mcp/describe.ts @@ -25,6 +25,11 @@ export function describeCall(tool: string, input: Input, state: RootState): stri end_work: () => `Finished${input.summary ? `: ${text(input.summary)}` : ''}`, get_project_status: () => 'Checked the project', get_project_overview: () => 'Read the project overview', + search_modules: () => (input.query ? `Searched modules for “${text(input.query)}”` : 'Listed modules'), + get_module: () => `Read module ${text(input.name)}`, + add_module: () => `Added module ${text(input.name)}`, + set_module_input: () => `Answered ${text(input.variable)} of module ${text(input.name)}`, + remove_module: () => `Removed module ${text(input.name)}`, get_file: () => `Read ${file(input.fileId)}`, list_block_types: () => 'Listed block types', get_block_type: () => `Looked up ${type(input.blockType)}`, diff --git a/src/renderer/src/mcp/moduleTools.test.ts b/src/renderer/src/mcp/moduleTools.test.ts new file mode 100644 index 0000000..62850d0 --- /dev/null +++ b/src/renderer/src/mcp/moduleTools.test.ts @@ -0,0 +1,94 @@ +import { undone } from '../store/history'; +import { projectCreated, projectTypeChanged } from '../store/projectSlice'; +import { type TestStore, addBlock, addFile, makeStore } from '../store/test/storeTestUtils'; +import { installApi, uninstallApi } from '../test/render'; +import type { SessionTools, ToolEnv } from './shared'; +import { answerTool } from './tools'; + +const INDEX = { + 'delete-files': { + description: 'Deletes files', + tags: ['supported', 'management'], + version: '2.0.0', + input: [ + { + type: 'list', + variable: 'files', + label: 'Files', + subtype: [ + { key: 'path', type: 'string' }, + { key: 'why', type: 'string', default: 'Unknown' } + ] + } + ] + }, + 'promise-type-git': { description: 'Manage git repos', tags: ['supported', 'promise-type'], dependencies: ['library-for-promise-types-in-python'] }, + 'library-for-promise-types-in-python': { description: 'Library', tags: ['library'] }, + git: { alias: 'promise-type-git' } +}; + +let store: TestStore; +const envOf = (): ToolEnv => ({ canvas: null, dispatch: store.dispatch, getState: store.getState, session: {} as SessionTools }); +async function call(name: string, input: object = {}) { + const answer = await answerTool(envOf(), name, input); + if (!answer.ok) throw new Error(answer.content); + return JSON.parse(answer.content); +} +const refusal = async (name: string, input: object = {}) => (await answerTool(envOf(), name, input)).content; + +beforeEach(() => { + store = makeStore(); + store.dispatch(projectCreated({ name: 'Demo' })); + installApi({ + getModuleIndex: async () => ({ index: INDEX, fetchedAt: '2026-10-16T00:00:00Z', offline: false }), + getModuleDetails: async () => ({ + details: { readme: '

Use

Lists files.

', author: { name: 'Nick' } }, + offline: false + }) + }); +}); +afterEach(() => uninstallApi()); + +describe('module tools', () => { + it('search the index, old names included, libraries only when asked', async () => { + expect((await call('search_modules')).modules.map((m: { name: string }) => m.name)).toEqual([ + 'delete-files', + 'library-for-promise-types-in-python', + 'promise-type-git' + ]); + expect((await call('search_modules', { query: 'git' })).modules).toMatchObject([{ name: 'promise-type-git', kind: 'promise-type' }]); + expect((await call('search_modules', { tag: 'management' })).modules).toMatchObject([{ name: 'delete-files', kind: 'configured' }]); + }); + + it('read a module with its questions and README as text', async () => { + const module = await call('get_module', { name: 'delete-files' }); + expect(module.input).toMatchObject([{ variable: 'files', type: 'list', fields: [{ key: 'path' }, { key: 'why', default: 'Unknown' }] }]); + expect(module.readme).toContain('Use'); + expect(module.readme).toContain('files (https://x.test/y)'); + expect(await refusal('get_module', { name: 'nope' })).toMatch(/No module nope/); + }); + + it('add a module with its dependencies, answer its input, and undo', async () => { + expect(await call('add_module', { name: 'promise-type-git' })).toMatchObject({ added: ['promise-type-git', 'library-for-promise-types-in-python'] }); + expect(await call('add_module', { name: 'delete-files' })).toMatchObject({ input: expect.stringContaining('set_module_input') }); + expect(await refusal('set_module_input', { name: 'delete-files', variable: 'files', response: [{ path: '/a', reason: 'x' }] })).toMatch(/no field reason/); + expect(await call('set_module_input', { name: 'delete-files', variable: 'files', response: [{ path: '/a' }] })).toEqual({}); + expect(store.getState().modules.find(m => m.name === 'delete-files')?.input).toEqual({ files: [{ path: '/a', why: 'Unknown' }] }); + expect((await call('get_module', { name: 'delete-files' })).project).toEqual({ usedBy: [] }); + store.dispatch(undone()); + expect((await call('get_module', { name: 'delete-files' })).project.needsInput).toBe(true); + }); + + it('remove a module unless a block uses it; a dependency goes with it', async () => { + await call('add_module', { name: 'promise-type-git' }); + const fileId = addFile(store); + addBlock(store, fileId, { blockId: 'git-checkout', label: 'Clone app' }); + expect(await refusal('remove_module', { name: 'promise-type-git' })).toMatch(/used by Clone app/); + expect(await refusal('remove_module', { name: 'library-for-promise-types-in-python' })).toMatch(/came with promise-type-git/); + }); + + it('refuse a module project', async () => { + store.dispatch(projectTypeChanged({ type: 'module' })); + expect(await refusal('add_module', { name: 'delete-files' })).toMatch(/policy-set projects/); + }); +}); diff --git a/src/renderer/src/mcp/moduleTools.ts b/src/renderer/src/mcp/moduleTools.ts new file mode 100644 index 0000000..b2af042 --- /dev/null +++ b/src/renderer/src/mcp/moduleTools.ts @@ -0,0 +1,206 @@ +import { requiredModule } from '../blocks/requiredModule'; +import { type CatalogModule, TAG_CHIPS, filterCatalog, modulesToAdd, modulesToRemove, parseIndex } from '../modules/catalog'; +import { type ReadmeNode, parseDetails } from '../modules/moduleDetails'; +import { isAnswered, isSupported, needsInput } from '../modules/moduleInput'; +import type { RootState } from '../store'; +import { type ModuleInputResponse, moduleInputChanged, modulesAdded, modulesRemoved } from '../store/modulesSlice'; +import { ToolError, optionalStr, str } from './shared'; +import type { Input, Tool, ToolEnv } from './shared'; + +/** CFEngine Build modules (build.cfengine.com): searched, read, added to the project and answered. */ + +let catalogRequest: Promise<{ byName: Map; modules: CatalogModule[]; offline: boolean }> | null = null; + +function catalog() { + catalogRequest ??= (async () => { + if (!window.api) throw new ToolError('Modules need the Policy Builder desktop app'); + const result = await window.api.getModuleIndex(); + if (!result.index) { + catalogRequest = null; + throw new ToolError(`Can’t load the module list (offline?): ${result.error ?? 'no copy downloaded yet'}`); + } + const modules = parseIndex(result.index); + return { modules, byName: new Map(modules.map(module => [module.name, module])), offline: result.offline }; + })(); + return catalogRequest; +} + +async function moduleOf(name: string) { + const { byName } = await catalog(); + const module = byName.get(name); + if (!module) throw new ToolError(`No module ${name}; search_modules finds them`); + return module; +} + +// Modules go into a policy set's build; a module project can't hold them. +function policySet(state: RootState) { + if (!state.project) throw new ToolError('No project is open in Policy Builder; open or create one first'); + if (state.project.type === 'module') throw new ToolError('This project is a cfbs module: modules go into policy-set projects'); +} + +const usedBy = (state: RootState, name: string) => state.canvas.filter(block => requiredModule(block) === name).map(block => block.label); + +// The README as plain text, headings and list items on lines of their own. +function readmeText(nodes: ReadmeNode[]): string { + const blocks = new Set(['p', 'div', 'pre', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'dt', 'dd', 'blockquote', 'figure', 'br', 'hr']); + return nodes + .map(node => { + if (typeof node === 'string') return node; + const inner = readmeText(node.children); + if (node.tag === 'img') return `[image: ${node.alt ?? ''}]`; + if (node.tag === 'a' && node.href && inner.trim() && inner.trim() !== node.href) return `${inner} (${node.href})`; + return blocks.has(node.tag) ? `\n${node.tag === 'li' ? '- ' : ''}${inner}\n` : inner; + }) + .join('') + .replace(/\n{3,}/g, '\n\n'); +} + +const README_LIMIT = 8000; + +async function readme(module: CatalogModule) { + const result = await window.api?.getModuleDetails(module.name).catch(() => null); + if (!result?.details) return { readme: undefined, author: undefined, downloads: undefined }; + const details = parseDetails(result.details); + const text = readmeText(details.readme).trim(); + return { + author: details.author?.name, + downloads: details.downloads, + readme: text.length > README_LIMIT ? `${text.slice(0, README_LIMIT)}… (cut; the module's page has the rest)` : text + }; +} + +const summary = (module: CatalogModule, state: RootState) => ({ + name: module.name, + kind: module.kind, + version: module.version, + description: module.description, + tags: module.tags, + experimental: module.experimental || undefined, + inProject: state.modules.some(added => added.name === module.name) || undefined +}); + +async function searchModules(env: ToolEnv, input: Input) { + const { modules, offline } = await catalog(); + const tag = optionalStr(input, 'tag'); + const found = filterCatalog(modules, { search: optionalStr(input, 'query') ?? '', supportedOnly: input.supportedOnly === true, tag: tag ?? null }, true); + const state = env.getState(); + return { + modules: found.slice(0, 40).map(({ module, alias }) => ({ ...summary(module, state), alsoKnownAs: alias })), + more: found.length > 40 ? `${found.length - 40} more: narrow the query or pick a tag` : undefined, + tags: TAG_CHIPS.map(chip => chip.tag), + offline: offline || undefined + }; +} + +async function getModule(env: ToolEnv, input: Input) { + const module = await moduleOf(str(input, 'name')); + const state = env.getState(); + const added = state.modules.find(item => item.name === module.name); + return { + ...summary(module, state), + supported: module.supported, + by: module.by, + source: module.repo, + dependencies: module.dependencies, + aliases: module.aliases.length ? module.aliases : undefined, + promiseType: + module.kind === 'promise-type' + ? 'Adds a promise type: use its block (list_block_types, category "From modules") or Custom Promise. Hosts need python3.' + : undefined, + input: module.input.length + ? module.input.map(question => ({ + variable: question.variable, + label: question.label, + question: question.question, + type: question.type, + fields: Array.isArray(question.subtype) + ? question.subtype.map(field => ({ key: field.key, label: field.label, question: field.question, default: field.default })) + : undefined, + listOf: question.subtype && !Array.isArray(question.subtype) ? 'text' : undefined, + supported: isSupported(question) || undefined, + answer: added?.input?.[question.variable ?? ''], + answered: added ? isAnswered(question, added.input?.[question.variable ?? '']) : undefined + })) + : undefined, + project: added + ? { addedFor: added.addedBy, needsInput: needsInput(module.input, added.input) || undefined, usedBy: usedBy(state, module.name) } + : undefined, + ...(await readme(module)) + }; +} + +async function addModule(env: ToolEnv, input: Input) { + policySet(env.getState()); + const { byName } = await catalog(); + const module = await moduleOf(str(input, 'name')); + const state = env.getState(); + if (state.modules.some(added => added.name === module.name)) throw new ToolError(`${module.name} is in the project already`); + const added = modulesToAdd(byName, module.name, state.modules); + env.dispatch(modulesAdded(added)); + return { + added: added.map(item => item.name), + input: module.input.length ? 'It asks for input: answer each question with set_module_input (get_module lists them)' : undefined, + note: 'Installed with cfbs when the project is saved (needs the internet once); it runs before the project’s own policy.' + }; +} + +function removeModule(env: ToolEnv, input: Input) { + const state = env.getState(); + policySet(state); + const name = str(input, 'name'); + const added = state.modules.find(item => item.name === name); + if (!added) throw new ToolError(`${name} isn’t in the project`); + if (added.addedBy) throw new ToolError(`${name} came with ${added.addedBy}: it goes when nothing needs it`); + const blocks = usedBy(state, name); + if (blocks.length) throw new ToolError(`${name} is used by ${blocks.join(', ')}: remove those blocks first`); + return catalog().then(({ byName }) => { + const removed = modulesToRemove(byName, name, state.modules); + env.dispatch(modulesRemoved(removed)); + return { removed }; + }); +} + +// The answer as the question takes it: text, a list of texts, or rows of the question's fields. +function checkedResponse(question: CatalogModule['input'][number], response: unknown): ModuleInputResponse { + if (question.type === 'string') { + if (typeof response !== 'string') throw new ToolError(`${question.variable} takes text`); + return response; + } + if (!Array.isArray(response)) throw new ToolError(`${question.variable} takes a list`); + if (!Array.isArray(question.subtype)) { + if (!response.every(item => typeof item === 'string')) throw new ToolError(`${question.variable} takes a list of texts`); + return response as string[]; + } + const fields = question.subtype; + const keys = fields.map(field => field.key ?? ''); + return response.map((row, index) => { + if (typeof row !== 'object' || row === null || Array.isArray(row)) + throw new ToolError(`${question.variable}[${index}] takes an object with ${keys.join(', ')}`); + const unknown = Object.keys(row).filter(key => !keys.includes(key)); + if (unknown.length) throw new ToolError(`${question.variable}[${index}]: no field ${unknown.join(', ')}; its fields: ${keys.join(', ')}`); + const given = row as Record; + return Object.fromEntries(fields.map(field => [field.key ?? '', String(given[field.key ?? ''] ?? field.default ?? '')])); + }); +} + +async function setModuleInput(env: ToolEnv, input: Input) { + policySet(env.getState()); + const module = await moduleOf(str(input, 'name')); + if (!env.getState().modules.some(added => added.name === module.name)) throw new ToolError(`${module.name} isn’t in the project: add_module first`); + const variable = str(input, 'variable'); + const question = module.input.find(item => item.variable === variable); + if (!question) + throw new ToolError(`${module.name} has no question ${variable}; its questions: ${module.input.map(item => item.variable).join(', ') || 'none'}`); + if (!isSupported(question)) throw new ToolError(`${variable}'s input type isn’t supported in the builder: answer it with cfbs input`); + env.dispatch(moduleInputChanged({ name: module.name, variable, response: checkedResponse(question, input.response) })); + const added = env.getState().modules.find(item => item.name === module.name); + return { needsInput: needsInput(module.input, added?.input) || undefined }; +} + +export const MODULE_TOOLS: Record = { + search_modules: searchModules, + get_module: getModule, + add_module: addModule, + remove_module: removeModule, + set_module_input: setModuleInput +}; diff --git a/src/renderer/src/mcp/tools.ts b/src/renderer/src/mcp/tools.ts index a9f322d..278d650 100644 --- a/src/renderer/src/mcp/tools.ts +++ b/src/renderer/src/mcp/tools.ts @@ -1,12 +1,13 @@ import { CANVAS_TOOLS } from './canvasTools'; import { DEFINITION_TOOLS } from './definitionTools'; +import { MODULE_TOOLS } from './moduleTools'; import { PROJECT_TOOLS } from './projectTools'; import type { Input, Tool, ToolEnv } from './shared'; import { TEST_TOOLS } from './testTools'; import { WORK_TOOLS } from './workTools'; /** Every MCP tool by name (src/main/mcpTools/ declares them), answered as JSON text. */ -const TOOLS: Record = { ...WORK_TOOLS, ...PROJECT_TOOLS, ...CANVAS_TOOLS, ...DEFINITION_TOOLS, ...TEST_TOOLS }; +const TOOLS: Record = { ...WORK_TOOLS, ...PROJECT_TOOLS, ...CANVAS_TOOLS, ...DEFINITION_TOOLS, ...MODULE_TOOLS, ...TEST_TOOLS }; export interface ToolAnswer { content: string; From 26ca3ffde5f8dcb2125bbf186a0a7626266187e1 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 17:37:38 +0300 Subject: [PATCH 07/23] Tested downloading the module index and saving modules with cfbs Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/modules.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++ src/main/project.test.ts | 60 ++++++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 src/main/modules.test.ts diff --git a/src/main/modules.test.ts b/src/main/modules.test.ts new file mode 100644 index 0000000..bdc2023 --- /dev/null +++ b/src/main/modules.test.ts @@ -0,0 +1,69 @@ +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerModuleHandlers } from './modules'; +import { type Handler, invoker, ipcEvent, isTrustedFrame } from './test/ipc'; + +const electron = vi.hoisted(() => ({ handlers: new Map(), userData: '' })); + +vi.mock('electron', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + app: { ...original.app, getPath: () => electron.userData }, + ipcMain: { handle: (channel: string, handler: Handler) => electron.handlers.set(channel, handler) } + }; +}); + +const INDEX = { index: { 'delete-files': { description: 'Deletes files', version: '2.0.0' } } }; +const DETAILS = { + 'delete-files': { '1.0.0': { versions: { '1.0.0': { latest: false } } }, '2.0.0': { readme: '

Hi

', versions: { '2.0.0': { latest: true } } } } +}; + +const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; +const fetchMock = vi.fn(); +const invoke = invoker(electron.handlers); + +beforeAll(() => registerModuleHandlers(isTrustedFrame)); + +beforeEach(async () => { + electron.userData = await fs.mkdtemp(join(tmpdir(), 'cfpb-modules-')); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(async () => { + vi.unstubAllGlobals(); + await fs.rm(electron.userData, { recursive: true, force: true }); +}); + +describe('the module index', () => { + it('downloads the index and keeps a copy for offline use', async () => { + fetchMock.mockResolvedValueOnce(answer(INDEX)); + expect(await invoke('modules:index', true)).toMatchObject({ index: INDEX.index, offline: false }); + fetchMock.mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND')); + expect(await invoke('modules:index', true)).toMatchObject({ index: INDEX.index, offline: true, error: 'getaddrinfo ENOTFOUND' }); + }); + + it('says so when offline with no copy, and refuses what isn’t an index', async () => { + fetchMock.mockRejectedValueOnce(new Error('offline')); + expect(await invoke('modules:index', true)).toEqual({ fetchedAt: null, index: null, offline: true, error: 'offline' }); + fetchMock.mockResolvedValueOnce(answer({ modules: [] })); + expect(await invoke('modules:index', true)).toMatchObject({ index: null, error: 'not a cfbs index' }); + }); + + it('refuses an untrusted sender', () => { + expect(() => electron.handlers.get('modules:index')?.(ipcEvent(false), true)).toThrow('untrusted sender'); + }); +}); + +describe('module details', () => { + it('gives the latest version’s details, and refuses a name that isn’t one', async () => { + fetchMock.mockResolvedValue(answer(DETAILS)); + expect(await invoke('modules:details', 'delete-files')).toMatchObject({ details: { readme: '

Hi

' }, offline: false }); + expect(await invoke('modules:details', 'nope')).toMatchObject({ details: null, error: expect.stringContaining('doesn’t list it') }); + expect(() => invoke('modules:details', '../etc')).toThrow('invalid module name'); + }); +}); diff --git a/src/main/project.test.ts b/src/main/project.test.ts index 40fde73..9f081f0 100644 --- a/src/main/project.test.ts +++ b/src/main/project.test.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ProjectContent } from '../preload/api'; -import { compilePolicy, initCfbsProject } from './backend'; +import { compilePolicy, initCfbsProject, syncModules } from './backend'; import { checkedAbsolutePath, checkedContent, @@ -34,7 +34,7 @@ vi.mock('electron', async importOriginal => { }; }); -vi.mock('./backend', () => ({ compilePolicy: vi.fn(), initCfbsProject: vi.fn(), masterfilesEntry: vi.fn() })); +vi.mock('./backend', () => ({ compilePolicy: vi.fn(), initCfbsProject: vi.fn(), masterfilesEntry: vi.fn(), syncModules: vi.fn() })); let temp = ''; @@ -329,6 +329,62 @@ describe('IPC handlers', () => { expect(() => electron.handlers.get('project:save')?.(ipcEvent(false), {})).toThrow('untrusted sender'); }); + describe('CFEngine Build modules', () => { + const sync = vi.mocked(syncModules); + const files = { variable: 'files', type: 'list', subtype: [{ key: 'path', type: 'string' }] }; + const entry = { name: 'delete-files', commit: 'abc', version: '2.0.0', added_by: 'cfbs add', input: [files] }; + const cfbs = async () => JSON.parse(await fs.readFile(join(project(), 'cfbs.json'), 'utf-8')); + const inputFile = () => join(project(), 'delete-files', 'input.json'); + + beforeEach(() => { + sync.mockReset(); + // As cfbs add / remove do: the entries go into cfbs.json, dependencies first. + sync.mockImplementation(async (directory, add, remove) => { + const path = join(directory, 'cfbs.json'); + const config = JSON.parse(await fs.readFile(path, 'utf-8')); + config.build = [...config.build.filter((item: { name: string }) => !remove.includes(item.name)), ...(add.includes('delete-files') ? [entry] : [])]; + await fs.writeFile(path, JSON.stringify(config)); + }); + }); + + it('adds a module with cfbs on save, writes its answers, and opens with them', async () => { + await open(); + const buildModules = [{ name: 'delete-files', input: { files: [{ path: '/etc/hosts.equiv' }] } }]; + expect((await save({ buildModules })).ok).toBe(true); + expect(sync).toHaveBeenCalledWith(project(), ['delete-files'], []); + // Modules before the project's own policy. + expect((await cfbs()).build.map((item: { name: string }) => item.name)).toEqual(['masterfiles', 'delete-files', './main.cf']); + expect(JSON.parse(await fs.readFile(inputFile(), 'utf-8'))).toEqual([{ ...files, response: [{ path: '/etc/hosts.equiv' }] }]); + expect(await open()).toMatchObject({ moduleInputs: { 'delete-files': [{ variable: 'files', response: [{ path: '/etc/hosts.equiv' }] }] } }); + + sync.mockClear(); + expect((await save({ buildModules })).ok).toBe(true); + expect(sync).not.toHaveBeenCalled(); + }); + + it('removes a module the project dropped, and its input file', async () => { + await open(); + await save({ buildModules: [{ name: 'delete-files', input: { files: [] } }] }); + expect((await save({ buildModules: [] })).ok).toBe(true); + expect(sync).toHaveBeenLastCalledWith(project(), [], ['delete-files']); + await expect(fs.stat(join(project(), 'delete-files'))).rejects.toThrow(); + }); + + it('refuses a module name that isn’t one, and leaves a module project alone', async () => { + await open(); + expect(await save({ buildModules: [{ name: '../evil' }] })).toMatchObject({ ok: false, message: 'Invalid modules' }); + expect((await save({ buildModules: [{ name: 'delete-files' }], storage: { type: 'module', masterfiles: null } })).ok).toBe(true); + expect(sync).not.toHaveBeenCalled(); + }); + + it('fails the save, writing nothing, when cfbs can’t add a module', async () => { + sync.mockRejectedValue(new Error('Couldn’t download delete-files — check your network connection.')); + await open(); + expect(await save({ buildModules: [{ name: 'delete-files' }] })).toMatchObject({ ok: false, message: expect.stringContaining('network') }); + await expect(fs.stat(join(project(), 'main.cf'))).rejects.toThrow(); + }); + }); + it('saves only into a project opened in this session', async () => { expect(await save()).toMatchObject({ ok: false, message: 'Not a project opened in this session' }); expect(compiled).not.toHaveBeenCalled(); From 9a5c48ebee78d78f578b3ef5dece4c36732dd574 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 17:39:41 +0300 Subject: [PATCH 08/23] Tested the Modules view, module pages and modules from the Blocks palette Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/modules/Modules.test.tsx | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/renderer/src/components/modules/Modules.test.tsx diff --git a/src/renderer/src/components/modules/Modules.test.tsx b/src/renderer/src/components/modules/Modules.test.tsx new file mode 100644 index 0000000..41a1eb0 --- /dev/null +++ b/src/renderer/src/components/modules/Modules.test.tsx @@ -0,0 +1,144 @@ +import { Provider } from 'react-redux'; + +import { fireEvent, render, screen, within } from '@testing-library/react'; + +import App from '../../App'; +import { AppThemeProvider } from '../../ThemeProvider'; +import { projectFilesInitialized } from '../../store/filesSlice'; +import { projectCreated } from '../../store/projectSlice'; +import { type TestStore, makeStore } from '../../store/test/storeTestUtils'; +import { installApi, uninstallApi } from '../../test/render'; + +const INDEX = { + 'delete-files': { + description: 'Deletes files you list', + tags: ['supported', 'management'], + version: '2.0.0', + repo: 'https://github.com/nickanderson/cfengine-delete-files', + input: [ + { + type: 'list', + variable: 'files', + label: 'Files', + subtype: [ + { key: 'path', type: 'string', label: 'Path' }, + { key: 'why', type: 'string', label: 'Why', default: 'Unknown' } + ] + } + ] + }, + clamav: { description: 'Runs ClamAV scans', tags: ['experimental', 'security'], version: '0.1.0' }, + 'promise-type-git': { + description: 'Manage git repos', + tags: ['supported', 'promise-type'], + version: '0.2.6', + dependencies: ['library-for-promise-types-in-python'] + }, + 'library-for-promise-types-in-python': { description: 'Python library', tags: ['library'], version: '0.3.1' } +}; +const README = '

Delete files

See the repository.

'; + +let store: TestStore; + +// A project in memory, open in the app. +async function renderApp() { + store.dispatch(projectCreated({ name: 'Mods' })); + store.dispatch(projectFilesInitialized('Mods', [])); + render( + + + + + + ); + await screen.findByPlaceholderText('Search blocks…'); +} + +const sideBar = () => screen.getByRole('navigation', { name: 'Side bar views' }); + +beforeEach(() => { + store = makeStore(); + installApi({ + getModuleIndex: async () => ({ index: INDEX, fetchedAt: '2026-10-16T00:00:00Z', offline: false }), + getModuleDetails: async () => ({ + details: { readme: README, author: { name: 'Nick Anderson' }, downloads: 89, versions: { '2.0.0': { date: 'Jun 30, 2023', latest: true } } }, + offline: false + }) + }); +}); +afterEach(() => uninstallApi()); + +describe('the Modules view', () => { + it('lists, searches and filters the index; libraries only on request', async () => { + await renderApp(); + fireEvent.click(within(sideBar()).getByRole('button', { name: 'Modules' })); + expect(await screen.findByText('delete-files')).toBeInTheDocument(); + expect(screen.getByText(/All modules \(3\)/i)).toBeInTheDocument(); + expect(screen.queryByText('library-for-promise-types-in-python')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Search modules…'), { target: { value: 'git' } }); + expect(screen.getByText('promise-type-git')).toBeInTheDocument(); + expect(screen.queryByText('delete-files')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Search modules…'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Libraries' })); + expect(screen.getByText('library-for-promise-types-in-python')).toBeInTheDocument(); + }); + + it('opens a module’s page, adds it, and answers its input', async () => { + await renderApp(); + fireEvent.click(within(sideBar()).getByRole('button', { name: 'Modules' })); + fireEvent.click(await screen.findByText('delete-files')); + + expect(screen.getByRole('heading', { name: 'delete-files' })).toBeInTheDocument(); + // The README as plain markup: its link opens outside the app, its script is gone. + expect(await screen.findByRole('heading', { name: 'Delete files' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'repository' })).toHaveAttribute('href', 'https://github.com/x/y'); + expect(document.querySelector('script')).toBeNull(); + expect(screen.getByText('Total downloads: 89')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add to project' })); + expect(store.getState().modules).toEqual([{ name: 'delete-files', version: '2.0.0' }]); + expect(within(sideBar()).getByText('1')).toBeInTheDocument(); // needs input + + fireEvent.click(screen.getByRole('tab', { name: 'Input' })); + // The Input tab's Add (the module list's rows have theirs). + fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1)!); + fireEvent.change(screen.getByRole('textbox', { name: 'Path' }), { target: { value: '/etc/hosts.equiv' } }); + expect(store.getState().modules[0].input).toEqual({ files: [{ path: '/etc/hosts.equiv', why: 'Unknown' }] }); + expect(within(sideBar()).queryByText('1')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Back to Canvas/ })); + expect(screen.queryByRole('heading', { name: 'delete-files' })).not.toBeInTheDocument(); + }); + + it('collapses the side bar from its open view’s icon', async () => { + await renderApp(); + expect(screen.getByPlaceholderText('Search blocks…')).toBeInTheDocument(); + fireEvent.click(within(sideBar()).getByRole('button', { name: 'Blocks and files' })); + expect(screen.queryByPlaceholderText('Search blocks…')).not.toBeInTheDocument(); + fireEvent.click(within(sideBar()).getByRole('button', { name: 'Blocks and files' })); + expect(screen.getByPlaceholderText('Search blocks…')).toBeInTheDocument(); + }); +}); + +describe('modules from the Blocks palette', () => { + it('points a search no block matches at the modules that do', async () => { + await renderApp(); + fireEvent.change(screen.getByPlaceholderText('Search blocks…'), { target: { value: 'clamav' } }); + fireEvent.click(await screen.findByRole('button', { name: 'Browse modules…' })); + expect(screen.getByPlaceholderText('Search modules…')).toHaveValue('clamav'); + expect(screen.getByText('clamav')).toBeInTheDocument(); + }); + + it('brings a promise type’s module with its block, and keeps it while the block is there', async () => { + await renderApp(); + fireEvent.click(screen.getByText(/^Git Checkout$/)); + expect(store.getState().modules).toEqual([{ name: 'promise-type-git' }]); + + fireEvent.click(within(sideBar()).getByRole('button', { name: 'Modules' })); + fireEvent.click(await screen.findByText('promise-type-git')); + expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled(); + expect(screen.getByText(/Used by Git Checkout/)).toBeInTheDocument(); + }); +}); From cb9aadfcf065a1ccb51ed68c717072092816f691 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 17:42:11 +0300 Subject: [PATCH 09/23] Tested saving a module with the real sidecar and cfbs, offline The module comes from a local git repository listed by a custom index, so cfbs adds and builds it without the network. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/project.integration.test.ts | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/main/project.integration.test.ts b/src/main/project.integration.test.ts index 8741f9d..73f99c6 100644 --- a/src/main/project.integration.test.ts +++ b/src/main/project.integration.test.ts @@ -233,3 +233,68 @@ describe('module projects', () => { expect(await buildNames()).toEqual(['./main.cf', './services/']); }); }); + +describe('CFEngine Build modules', () => { + // A module in a local git repository, listed by a custom index: cfbs adds and fetches it offline. + async function localModule(): Promise { + const repo = join(temp, 'tiny-module'); + await fs.mkdir(repo); + await fs.writeFile(join(repo, 'policy.cf'), 'bundle agent tiny_main\n{\n reports:\n "$(tiny:main.greeting)";\n}\n'); + git(repo, 'init', '-q'); + git(repo, 'add', '.'); + git(repo, '-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'Tiny module'); + const commit = git(repo, 'rev-parse', 'HEAD'); + // cfbs takes a custom index by a path relative to the project. + const index = join(project(), 'index.json'); + const entry = { + description: 'A tiny module', + tags: ['experimental'], + repo: `file://${repo}`, + by: 'https://github.com/example', + version: '1.0.0', + commit, + steps: [ + 'copy policy.cf services/cfbs/modules/tiny-module/policy.cf', + 'input ./input.json def.json', + 'policy_files services/cfbs/modules/tiny-module/policy.cf', + 'bundles tiny_main' + ], + input: [{ type: 'string', variable: 'greeting', namespace: 'tiny', bundle: 'main', label: 'Greeting' }] + }; + await fs.writeFile(index, JSON.stringify({ name: 'test-index', type: 'index', index: { 'tiny-module': entry } })); + await fs.writeFile(join(project(), 'cfbs.json'), JSON.stringify({ ...(await readJson(join(project(), 'cfbs.json'))), index: './index.json' })); + return commit; + } + + beforeEach(() => vi.stubEnv('CFBS_GLOBAL_DIR', join(temp, 'cfbs-global'))); + + it('adds a module with cfbs on save, writes its answers, builds with them, and removes it', async () => { + const content = builderContent([mainFile]); + expect((await create(content)).ok).toBe(true); + const commit = await localModule(); + + const buildModules = [{ name: 'tiny-module', input: { greeting: 'hello' } }]; + expect(await save({ ...content, buildModules })).toEqual({ ok: true, masterfiles: null }); + expect(await buildNames()).toEqual(['tiny-module', './main.cf']); + expect(((await readJson(join(project(), 'cfbs.json'))).build as Json[])[0]).toMatchObject({ commit, added_by: 'cfbs add' }); + expect(await readJson(join(project(), 'tiny-module', 'input.json'))).toEqual([expect.objectContaining({ variable: 'greeting', response: 'hello' })]); + expect(await open()).toMatchObject({ moduleInputs: { 'tiny-module': [expect.objectContaining({ response: 'hello' })] } }); + + execFileSync(join(process.cwd(), 'python/.venv/bin/cfbs'), ['build'], { cwd: project(), stdio: 'pipe' }); + expect(await readJson(join(project(), 'out', 'masterfiles', 'def.json'))).toMatchObject({ variables: { 'tiny:main.greeting': { value: 'hello' } } }); + + expect(await save({ ...content, buildModules: [] })).toEqual({ ok: true, masterfiles: null }); + expect(await buildNames()).toEqual(['./main.cf']); + expect(await exists(join(project(), 'tiny-module'))).toBe(false); + }); + + it('fails the save, leaving the project as it was, for a module cfbs doesn’t know', async () => { + const content = builderContent([mainFile]); + expect((await create(content)).ok).toBe(true); + await localModule(); + const before = await fs.readFile(join(project(), 'cfbs.json'), 'utf-8'); + const result = await save({ ...content, buildModules: [{ name: 'no-such-module' }] }); + expect(result).toMatchObject({ ok: false, message: expect.stringContaining('no-such-module') }); + expect(await fs.readFile(join(project(), 'cfbs.json'), 'utf-8')).toBe(before); + }); +}); From 61967d710efa3f22a20c44495dd92c0419b079d7 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 17:46:20 +0300 Subject: [PATCH 10/23] A module project lists CFEngine Build modules but can't add them Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/modules/ModulePage.tsx | 6 ++- .../src/components/modules/ModuleRow.tsx | 40 ++++++++++++++----- .../src/components/modules/Modules.test.tsx | 11 ++++- .../src/components/modules/ModulesView.tsx | 6 ++- src/renderer/src/pages/SideBar.tsx | 1 + src/renderer/src/pages/useProjectModules.ts | 4 ++ 6 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/modules/ModulePage.tsx b/src/renderer/src/components/modules/ModulePage.tsx index 61f6210..4a2dd99 100644 --- a/src/renderer/src/components/modules/ModulePage.tsx +++ b/src/renderer/src/components/modules/ModulePage.tsx @@ -49,6 +49,8 @@ interface ModulePageProps { // Its place in the project's build order, when added. added?: { index: number; module: ProjectModule; total: number }; backLabel: string; + // False in a module project: modules go into policy sets. + canAdd: boolean; module: CatalogModule; needsInput: boolean; onAdd: () => void; @@ -92,6 +94,7 @@ function Description({ web }: { web: ModuleDetailsState }) { export function ModulePage({ added, backLabel, + canAdd, module, needsInput, onAdd, @@ -140,7 +143,7 @@ export function ModulePage({ ) ) : ( - )} @@ -159,6 +162,7 @@ export function ModulePage({ {added?.module.addedBy && Added for {added.module.addedBy}: it’s removed with it, unless another module needs it.} {!added && wouldAdd.length > 0 && Adding it also adds {wouldAdd.join(', ')}.} + {!canAdd && !added && This project is a cfbs module: modules go into policy-set projects (Project Settings).} {usedBy.length > 0 && Used by {usedBy.join(', ')}: delete those blocks to remove it.} {module.kind === 'promise-type' && Adds a promise type for your own policy. Hosts need python3 to run it.} diff --git a/src/renderer/src/components/modules/ModuleRow.tsx b/src/renderer/src/components/modules/ModuleRow.tsx index eeae3d4..001cff7 100644 --- a/src/renderer/src/components/modules/ModuleRow.tsx +++ b/src/renderer/src/components/modules/ModuleRow.tsx @@ -44,6 +44,8 @@ interface ModuleRowProps { // A dependency: the module it came with. addedFor?: string; alias?: string; + // False in a module project: modules go into policy sets. + canAdd: boolean; // In the project section (gear menu), else in the catalog (Add). inProject: boolean; module: CatalogModule; @@ -111,7 +113,21 @@ function GearMenu({ addedFor, module, onEditInput, onRemove, usedBy }: Pick Added ) : ( - + canAdd && ( + + ) )} {addedFor ? ( diff --git a/src/renderer/src/components/modules/Modules.test.tsx b/src/renderer/src/components/modules/Modules.test.tsx index 41a1eb0..d53fae5 100644 --- a/src/renderer/src/components/modules/Modules.test.tsx +++ b/src/renderer/src/components/modules/Modules.test.tsx @@ -5,7 +5,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import App from '../../App'; import { AppThemeProvider } from '../../ThemeProvider'; import { projectFilesInitialized } from '../../store/filesSlice'; -import { projectCreated } from '../../store/projectSlice'; +import { projectCreated, projectTypeChanged } from '../../store/projectSlice'; import { type TestStore, makeStore } from '../../store/test/storeTestUtils'; import { installApi, uninstallApi } from '../../test/render'; @@ -112,6 +112,15 @@ describe('the Modules view', () => { expect(screen.queryByRole('heading', { name: 'delete-files' })).not.toBeInTheDocument(); }); + it('lists modules in a module project, but doesn’t add them', async () => { + await renderApp(); + store.dispatch(projectTypeChanged({ type: 'module' })); + fireEvent.click(within(sideBar()).getByRole('button', { name: 'Modules' })); + fireEvent.click(await screen.findByText('delete-files')); + expect(screen.getByRole('button', { name: 'Add to project' })).toBeDisabled(); + expect(screen.getByText(/modules go into policy-set projects/)).toBeInTheDocument(); + }); + it('collapses the side bar from its open view’s icon', async () => { await renderApp(); expect(screen.getByPlaceholderText('Search blocks…')).toBeInTheDocument(); diff --git a/src/renderer/src/components/modules/ModulesView.tsx b/src/renderer/src/components/modules/ModulesView.tsx index 8088fb7..96db3dc 100644 --- a/src/renderer/src/components/modules/ModulesView.tsx +++ b/src/renderer/src/components/modules/ModulesView.tsx @@ -60,6 +60,8 @@ function Section({ children, count, title }: { children: ReactNode; count: numbe } interface ModulesViewProps { + // False in a module project: modules go into policy sets. + canAdd: boolean; catalog: ModuleCatalog; // What the search starts with (Browse modules… from the Blocks palette). initialSearch?: string; @@ -75,6 +77,7 @@ interface ModulesViewProps { /** The CFEngine Build modules: the project's, then the whole index, searched and filtered by tag. */ export function ModulesView({ + canAdd, catalog, initialSearch = '', isNeedingInput, @@ -118,7 +121,8 @@ export function ModulesView({ onAdd: () => onAdd(module.name), onEditInput: () => onEditInput(module.name), onRemove: () => onRemove(module.name), - usedBy: usedBy(module.name) + usedBy: usedBy(module.name), + canAdd }); return ( diff --git a/src/renderer/src/pages/SideBar.tsx b/src/renderer/src/pages/SideBar.tsx index a0ac6e6..186ab2d 100644 --- a/src/renderer/src/pages/SideBar.tsx +++ b/src/renderer/src/pages/SideBar.tsx @@ -27,6 +27,7 @@ export function SideBar({ modules, onResize, onResizeEnd, policy, view, widthFra state.modules); + // Modules go into policy sets: a module project lists them, but can't add one. + const canAdd = useAppSelector(state => state.project?.type !== 'module'); const canvas = useAppSelector(state => state.canvas); // The blocks whose promise type a module provides, by label. const usedBy = (name: string) => canvas.filter(block => requiredModule(block) === name).map(block => block.label); @@ -58,6 +60,7 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { onRemove: () => remove(open.name), usedBy: usedBy(open.name), onBack: close, + canAdd, onInputChange: (variable: string, response: ModuleInputResponse) => dispatch(moduleInputChanged({ name: open.name, variable, response })), onOpenModule: (name: string) => openPage(name), openInput @@ -65,6 +68,7 @@ export function useProjectModules(viewOpen: boolean, backLabel: string) { return { add, + canAdd, browse: (search: string) => setBrowsing(current => ({ search, count: current.count + 1 })), browsing, want: () => setWanted(true), From 4cdfba6e67a9fbc80d5b9c00f779682ad4b18121 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 17:48:43 +0300 Subject: [PATCH 11/23] Tested modules end to end: browsing, pages, input, promise-type blocks, saving Development builds read the module index and details from the folder CFPB_MODULE_DATA names, so the journey runs offline on e2e/modules/. Saving with cfbs downloads, so that one is opt-in with CFPB_NETWORK_TESTS. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/fixtures.ts | 2 + e2e/modules.spec.ts | 96 ++++++++++++++++++++++ e2e/modules/details.json | 170 +++++++++++++++++++++++++++++++++++++++ e2e/modules/index.json | 156 +++++++++++++++++++++++++++++++++++ src/main/modules.ts | 6 ++ 5 files changed, 430 insertions(+) create mode 100644 e2e/modules.spec.ts create mode 100644 e2e/modules/details.json create mode 100644 e2e/modules/index.json diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 5f6f6a5..585abdc 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -73,6 +73,8 @@ export async function launch(userDataDir: string): Promise >; if (HIDDEN_RUN) env.CFPB_E2E_HIDDEN = '1'; env.CFPB_E2E = '1'; + // The module index and details from e2e/modules/, offline; the network journeys use build.cfengine.com's. + if (!process.env.CFPB_NETWORK_TESTS) env.CFPB_MODULE_DATA = join(__dirname, 'modules'); return electron.launch({ args: [appEntry, `--user-data-dir=${userDataDir}`], env }); } diff --git a/e2e/modules.spec.ts b/e2e/modules.spec.ts new file mode 100644 index 0000000..adcb512 --- /dev/null +++ b/e2e/modules.spec.ts @@ -0,0 +1,96 @@ +import { type Page } from '@playwright/test'; +import { readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; + +import { SAVE_TIMEOUT_MS, expect, test } from './fixtures'; + +// CFEngine Build modules: offline on the index and details in e2e/modules/; installing one with cfbs +// on save downloads it (and masterfiles), so that journey is opt-in with CFPB_NETWORK_TESTS=1. +const NETWORK = process.env.CFPB_NETWORK_TESTS === '1'; + +const activityBar = (window: Page) => window.getByRole('navigation', { name: 'Side bar views' }); +const searchModules = (window: Page) => window.getByPlaceholder('Search modules…'); + +async function openModule(window: Page, name: string) { + // Clicking the open view's icon would close it. + if (!(await searchModules(window).isVisible())) await activityBar(window).getByRole('button', { name: 'Modules' }).click(); + await searchModules(window).fill(name); + await window.getByText(name, { exact: true }).first().click(); + await expect(window.getByRole('heading', { name, exact: true })).toBeVisible(); +} + +test('browse modules, open their pages, add one and answer its input', async ({ window, consoleErrors }) => { + await window.getByText(/^Try Demo:/).click(); + await expect(window.locator('footer').getByText('Project: Nginx Web Server Demo')).toBeVisible(); + + await test.step('a block search that finds nothing points at matching modules', async () => { + await window.getByPlaceholder('Search blocks…').fill('clamav'); + await window.getByRole('button', { name: 'Browse modules…' }).click(); + await expect(searchModules(window)).toHaveValue('clamav'); + await expect(window.getByText('clamav', { exact: true }).first()).toBeVisible(); + }); + + await test.step('a module’s page shows its README and links, as build.cfengine.com does', async () => { + await window.getByText('clamav', { exact: true }).first().click(); + await expect(window.getByRole('heading', { name: 'clamav', exact: true })).toBeVisible(); + await expect(window.getByRole('heading', { name: 'cfengine-clamav' })).toBeVisible(); + await expect(window.getByRole('link', { name: /^Repository/ })).toHaveAttribute('href', /github\.com\/aleksandrychev\/cfengine-clamav/); + await expect(window.getByText(/^Total downloads:/)).toBeVisible(); + }); + + await test.step('adding a module with input asks for it, and answering clears the badge', async () => { + await openModule(window, 'delete-files'); + await window.getByRole('button', { name: 'Add to project' }).click(); + await expect(activityBar(window).getByText('1')).toBeVisible(); + await window.getByRole('tab', { name: 'Input' }).click(); + await window.getByRole('button', { name: 'Add', exact: true }).last().click(); + await window.getByRole('textbox', { name: 'Path' }).fill('/etc/hosts.equiv'); + await expect(activityBar(window).getByText('1')).toBeHidden(); + }); + + await test.step('undo takes the answer back', async () => { + await window.getByRole('button', { name: /Back to Canvas/ }).click(); + await window.keyboard.press('ControlOrMeta+z'); + await expect(activityBar(window).getByText('1')).toBeVisible(); + }); + + await test.step('a Git Checkout block brings its module, which stays while the block is there', async () => { + await activityBar(window).getByRole('button', { name: 'Blocks and files' }).click(); + await window.getByPlaceholder('Search blocks…').fill('git'); + await window.getByRole('button', { name: /^Git Checkout/ }).click(); + await openModule(window, 'promise-type-git'); + await expect(window.getByText(/^Used by Git Checkout/)).toBeVisible(); + await expect(window.getByRole('button', { name: 'Remove' })).toBeDisabled(); + }); + + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test.describe('with network', () => { + test.skip(!NETWORK, 'downloads masterfiles and modules: set CFPB_NETWORK_TESTS=1 to run'); + test.setTimeout(600_000); + + test('saving installs a module with cfbs, with its answers', async ({ window, scratchDir, consoleErrors }) => { + await window.getByRole('button', { name: 'New Project' }).click(); + const dialog = window.getByRole('dialog', { name: 'New Project' }); + await dialog.getByLabel('Project name').fill('Modules'); + await dialog.getByLabel('Initialize a git repository').uncheck(); + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(dialog).toBeHidden({ timeout: 300_000 }); + + await openModule(window, 'delete-files'); + await window.getByRole('button', { name: 'Add to project' }).click(); + await window.getByRole('tab', { name: 'Input' }).click(); + await window.getByRole('button', { name: 'Add', exact: true }).last().click(); + await window.getByRole('textbox', { name: 'Path' }).fill('/etc/hosts.equiv'); + await window.keyboard.press('ControlOrMeta+s'); + await expect(window.getByLabel('Unsaved changes')).toHaveCount(0, { timeout: SAVE_TIMEOUT_MS * 5 }); + + const folder = join(scratchDir, 'projects', readdirSync(join(scratchDir, 'projects'))[0]); + const build = JSON.parse(readFileSync(join(folder, 'cfbs.json'), 'utf-8')).build as { name: string }[]; + expect(build.map(entry => entry.name)).toContain('delete-files'); + const input = JSON.parse(readFileSync(join(folder, 'delete-files', 'input.json'), 'utf-8')); + expect(input[0].response).toEqual([{ path: '/etc/hosts.equiv', why: 'Unknown' }]); + expect(consoleErrors, 'console errors during the run').toEqual([]); + }); +}); diff --git a/e2e/modules/details.json b/e2e/modules/details.json new file mode 100644 index 0000000..57018c1 --- /dev/null +++ b/e2e/modules/details.json @@ -0,0 +1,170 @@ +{ + "delete-files": { + "2.0.0": { + "author": { + "image": "https://avatars.githubusercontent.com/u/202896?v=4", + "name": "Nick Anderson", + "url": "https://github.com/nickanderson" + }, + "versions": { + "0.0.1": { + "date": "Nov 1, 2022", + "latest": false + }, + "1.0.0": { + "date": "Dec 21, 2022", + "latest": false + }, + "2.0.0": { + "date": "Jun 30, 2023", + "latest": true + } + }, + "updated": "Jun 30, 2023", + "downloads": 89, + "repo": "https://github.com/nickanderson/cfengine-delete-files", + "documentation": null, + "website": null, + "commit": "84cce7c5653b6a5f2b5a28ebb33c697ffc676dd4", + "discussion": "https://github.com/cfengine/core/discussions/new?category=modules&title=delete-files&body=I+have+a+question+regarding...", + "discussionSearch": "https://github.com/cfengine/core/discussions/categories/modules?discussions_q=is%3Aopen+category%3AModules+delete-files", + "version": "2.0.0", + "readme": "\n

A simple module to delete specific files.

\n

\nIt allows you to specify a list of files you want deleted on hosts in your infrastructure. When this module is deployed as part of your policy set, every time CFEngine runs, it will check if those files exist, and delete them if they do.

\n

\nRecommendation: Enforce the removal of files known to be malicious or indicating insecure configuration.\nExamples of such files are `~/.shosts` files, `/etc/cron.deny`, etc.

\n
\n

\nConfiguration\n

\n
\n
\n

\nModule input\n

\n
\n

\nThis module accepts input.\nBoth the cfbs CLI and Mission Portal (3.21 and beyond) allow you to specify the files you want deleted.\nHere is an example of what it looks like in Mission Portal:

\n

\n\"https://raw.githubusercontent.com/nickanderson/cfengine-delete-files/main/delete-files-example-input-mp.png\"

\n
\n
\n
\n

\nVariables\n

\n
\n
\n
\ndelete_files:delete_files.files\n
\n
\n

Array of dicts having path and why keys.

\n
    \n
  • path must be a regular expression matching the fully qualified path to a file
  • \n
  • why should explain why the file being absent is important
  • \n
\n
\n
\n
   {\n     \"variables\": {\n       \"delete_files:delete_files.files\": {\n         \"comment\": \"Files and their required permissions\",\n         \"value\": [\n           {\n             \"why\": \"The file lists remote hosts and users that are trusted by the local system when using the rshd daemon and can allow unauthenticated access to the system. Reccomended by CIS  CCE-84145-2.\",\n             \"path\": \"/etc/hosts.equiv\"\n           },\n           {\n             \"why\": \"The file lists remote hosts and users that are trusted by the local system when using the rshd daemon and can allow unauthenticated access to the system. Reccomended by CIS  CCE-84145-2.\",\n             \"path\": \"/home/.*/.rhosts\"\n           },\n           {\n             \"why\": \"The file lists remote hosts and users that are trusted by the local system when using the rshd daemon and can allow unauthenticated access to the system. Reccomended by CIS  CCE-84145-2.\",\n             \"path\": \"/root/.rhosts\"\n           }\n         ]\n       }\n     }\n   }
\n
\n
\nExample Augments configuration\n
\n
\n

\nNotes:

\n
    \n
  • Running with class delete_files:DEBUG or default:DEBUG define results in DEBUG reports from policy.
  • \n
  • A list or array of files is also supported.
  • \n
  • An array with filenames and dicts having path and why keys is supported.
  • \n
  • Absence of why key is supported.
  • \n
  • A simple string is supported (but as indicated by DEBUG reports, likely a mis-configuration).
  • \n
  • Directories are not deleted.
  • \n
\n
\n
\n
\n
\n
\n
\n
\n

\nHistory\n

\n
\n
    \n
  • DEBUG reports added in version 2.0.0.
  • \n
  • Support for simple list specification added in version 2.0.0.
  • \n
  • Support for mixed data array specification added in version 2.0.0.
  • \n
  • Support for absence of why key added in version 2.0.0.
  • \n
\n
\n
\n" + } + }, + "clamav": { + "0.1.0": { + "author": { + "image": "https://avatars.githubusercontent.com/u/16775360?v=4", + "name": "Igor Aleksandrychev", + "url": "https://github.com/aleksandrychev" + }, + "versions": { + "0.0.1": { + "date": "Aug 28, 2026", + "latest": false + }, + "0.0.2": { + "date": "Aug 31, 2026", + "latest": false + }, + "0.0.3": { + "date": "Sep 1, 2026", + "latest": false + }, + "0.1.0": { + "date": "Sep 4, 2026", + "latest": true + } + }, + "updated": "Sep 4, 2026", + "downloads": 2, + "repo": "https://github.com/aleksandrychev/cfengine-clamav", + "documentation": null, + "website": null, + "commit": "6ebb89d7d54966b4a8cdbe8d68e510e47086fba8", + "discussion": "https://github.com/cfengine/core/discussions/new?category=modules&title=clamav&body=I+have+a+question+regarding...", + "discussionSearch": "https://github.com/cfengine/core/discussions/categories/modules?discussions_q=is%3Aopen+category%3AModules+clamav", + "version": "0.1.0", + "readme": "

cfengine-clamav

\n

\"CI\"

\n

CFEngine build module that installs ClamAV from upstream packages, keeps its signatures fresh, runs periodic scans, and reports the results as CFEngine Enterprise inventory.

\n

\"Mission

\n

What it does

\n
    \n
  • Installs the upstream ClamAV .deb/.rpm from clamav.net (Debian/Ubuntu and RHEL-family, x86_64/aarch64) and creates the clamav user.
  • \n
  • Runs freshclam when signatures are older than 2 days (configurable).
  • \n
  • Runs clamscan over /home, /root, /tmp, /var/tmp when the last report is older than 3 days (configurable).
  • \n
  • Parses the scan log and inventories: engine version, signature count, scanned files, infected files, data scanned, last scan time, signature freshness, and detected threats. Infections are reported as an alert on every run.
  • \n
\n

Only Linux is supported. Windows support is coming soon.

\n

Installation

\n
cfbs add clamav\ncfbs build\n

Configuration

\n

Scan intervals, scan targets, and excluded directories can be set interactively with cfbs input clamav. Any default can also be overridden via augments, e.g.:

\n
{\n  \"variables\": {\n    \"clamav:globals.clamav_version\": \"1.5.4\",\n    \"clamav:globals.max_age_days\": \"1\",\n    \"clamav:globals.signature_max_age_days\": \"1\",\n    \"clamav:globals.scan_target\": [\"/home\", \"/srv\"],\n    \"clamav:globals.exclude_dirs\": [\"/proc\", \"/sys\", \"/dev\", \"/run\", \"/srv/backup\"]\n  }\n}\n

Entries in exclude_dirs are directory paths without a trailing slash. The module anchors each one and passes it to clamscan --exclude-dir, which matches it as a path prefix, so /dev also covers /dev/shm. Setting the list replaces the built-in one (/proc, /sys, /dev, /run, /var/lib/clamav, /var/cfengine), so include any of those you still want.

\n

Trigger actions immediately:

\n
cf-agent -KI --define clamav:want_scan_now      # force a scan\ncf-agent -KI --define clamav:want_freshen_now   # force a signature update\n

Compliance report

\n

This module contains a ready-made Mission Portal compliance report covering infections, scan recency, and signature freshness - see compliance-report/README.md.

\n

Notes

\n
    \n
  • clamscan and freshclam load the full signature database into memory, ensure at least ~2 GB of available RAM on scanned hosts. On hosts with less memory, add a swap file with the manage-swap module.
  • \n
  • On Debian/Ubuntu, the distro\u2019s clamav-freshclam daemon locks the signature database while running, which causes the module\u2019s freshclam runs to fail. Disable it with systemctl disable --now clamav-freshclam if CFEngine should manage updates.
  • \n
\n" + } + }, + "promise-type-git": { + "0.2.6": { + "author": { + "image": "https://avatars.githubusercontent.com/u/1295287?v=4", + "name": "Fabio Tranchitella", + "url": "https://github.com/tranchitella" + }, + "versions": { + "0.1.0": { + "date": "Oct 26, 2021", + "latest": false + }, + "0.1.1": { + "date": "Nov 1, 2021", + "latest": false + }, + "0.2.2": { + "date": "Feb 8, 2023", + "latest": false + }, + "0.2.3": { + "date": "Mar 5, 2024", + "latest": false + }, + "0.2.4": { + "date": "Jun 18, 2024", + "latest": false + }, + "0.2.5": { + "date": "Sep 2, 2024", + "latest": false + }, + "0.2.6": { + "date": "Oct 28, 2024", + "latest": true + } + }, + "updated": "Oct 28, 2024", + "downloads": 8938, + "repo": "https://github.com/cfengine/modules", + "documentation": null, + "website": null, + "commit": "3055538003b5dc88c80547703368da33fd43a5a9", + "discussion": "https://github.com/cfengine/core/discussions/new?category=modules&title=promise-type-git&body=I+have+a+question+regarding...", + "discussionSearch": "https://github.com/cfengine/core/discussions/categories/modules?discussions_q=is%3Aopen+category%3AModules+promise-type-git", + "version": "0.2.6", + "readme": "

The git promise type enables writing concise policy for cloning a git repo and keeping it updated.

\n

Requirements

\n
    \n
  • git (the command line tool)
  • \n
\n

Attributes

\n\n\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\n\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\n\t\n
NameTypeDescriptionMandatoryDefault
destinationstringDestination pathNoPromiser
repositorystringGit repository URLYes-
barebooleanIf true, clones the repository as bare repositoryNo-
clonebooleanIf true, clones the repository if it doesn\u2019t exist at the destination pathNo-
depthintegerCreate a shallow clone with a history truncated to the specified number or revisions. Set to 0 to perform a full clone.No0
executablestringPath to the git executableNogit
forcebooleanIf true, discard any local changes to the repository before updating itNo-
recursivebooleanIf true, use the --recursive git optionNoyes
referencestringIf set, use the --reference git option with the given valueNo-
remotestringName of the git remoteNoorigin
ssh_executablestringPath to the ssh executableNossh
ssh_optionsstringAdditional options for the git command, e.g. -o StrictHostKeyChecking=noNo-
updatebooleanIf true, updates the repository if it already exists at the destination pathNo-
versionstringThe version of the repository to checkout. It can be a branch name, a tag name or a SHA-1 hash.NoHEAD
\n

Examples

\n

Check out a git repository in a given destination path:

\n
bundle agent main\n{\n  git:\n    \"starter_pack_repo\"\n      repository => \"https://github.com/cfengine/starter_pack\",\n      destination => \"/northern.tech/cfengine/starter-pack\",\n      version => \"master\";\n}\n

Full example with almost all the attributes:

\n
bundle agent main\n{\n  git:\n    \"starter_pack_repo\"\n      destination => \"/northern.tech/cfengine/starter_pack\",\n      repository => \"https://github.com/cfengine/starter_pack\",\n      bare => \"true\",\n      clone => \"true\",\n      depth => \"1\",\n      executable => \"/bin/git\",\n      force => \"true\",\n      recursive => \"true\",\n      remote => \"origin\",\n      ssh_options => \"UserKnownHostsFile=/dev/null\",\n      update => \"true\",\n      version => \"master\";\n}\n

Authentication

\n

This module will set the HOME environment variable if it is not set already based on the user running cf-agent, typically root.

\n

In order to add authentication you can use gitcredentials.

\n

An example of this usage would be to have two files in $HOME: .gitconfig and .git-credentials.

\n
    \n
  • .gitconfig
  • \n
\n
[credential]\n    helper = store\n
    \n
  • .git-credentials
  • \n
\n

Using the store helper places the username and password in plaintext in $HOME/.git-credentials.

\n

Here is an explanation of that file storage format:

\n
\n

The .git-credentials file is stored in plaintext. Each credential is stored on its own line as a URL like:

\n
\n
https://user:pass@example.com\n
\n

No other kinds of lines (e.g. empty lines or comment lines) are allowed in the file, even though some may be silently ignored. Do not view or edit the file with editors.

\n
\n

Authors

\n

This software was created by the team at Northern.tech, with many contributions from the community.\nThanks everyone!

\n

Contribute

\n

Feel free to open pull requests to expand this documentation, add features or fix problems.\nYou can also pick up an existing task or file an issue in our bug tracker.

\n

License

\n

This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text.

\n" + } + }, + "library-for-promise-types-in-python": { + "0.3.1": { + "author": { + "image": "https://avatars.githubusercontent.com/u/1239823?v=4", + "name": "CFEngine", + "url": "https://github.com/cfengine" + }, + "versions": { + "0.1.0": { + "date": "Oct 26, 2021", + "latest": false + }, + "0.1.1": { + "date": "Nov 1, 2021", + "latest": false + }, + "0.2.1": { + "date": "Feb 8, 2023", + "latest": false + }, + "0.2.2": { + "date": "Mar 5, 2024", + "latest": false + }, + "0.2.3": { + "date": "Jun 19, 2024", + "latest": false + }, + "0.3.0": { + "date": "Jul 15, 2025", + "latest": false + }, + "0.3.1": { + "date": "Apr 1, 2026", + "latest": true + } + }, + "updated": "Apr 1, 2026", + "downloads": 9072, + "repo": "https://github.com/cfengine/modules", + "documentation": null, + "website": null, + "commit": "679c6c0b18cdb517c7595b1701c3707f47106da5", + "discussion": "https://github.com/cfengine/core/discussions/new?category=modules&title=library-for-promise-types-in-python&body=I+have+a+question+regarding...", + "discussionSearch": "https://github.com/cfengine/core/discussions/categories/modules?discussions_q=is%3Aopen+category%3AModules+library-for-promise-types-in-python", + "version": "0.3.1", + "readme": "

A library for CFEngine custom promise types implemented in Python.\nIt provides the PromiseModule base class and many helper values and mechanisms that facilitates creation of custom promises.

\n

If you\u2019d like to develop new promise types (or other modules) for CFEngine, we recommend finishing our getting started tutorial:

\n

https://docs.cfengine.com/docs/3.21/getting-started.html

\n

(The last part shows implementing your first module).

\n

If you feel comfortable with using CFEngine and writing Python code already, there is a template project you can just copy and start editing:

\n

https://github.com/cfengine/promise-type-template

\n

For more detailed information about how custom promise types work, see our documentation:

\n

https://docs.cfengine.com/docs/3.21/reference-promise-types-custom.html

\n" + } + } +} \ No newline at end of file diff --git a/e2e/modules/index.json b/e2e/modules/index.json new file mode 100644 index 0000000..249811d --- /dev/null +++ b/e2e/modules/index.json @@ -0,0 +1,156 @@ +{ + "name": "index", + "type": "index", + "index": { + "delete-files": { + "description": "Allows you to specify a list of files you want deleted on hosts in your infrastructure. When this module is deployed as part of your policy set, every time CFEngine runs, it will check if those files exist, and delete them if they do.", + "tags": [ + "supported", + "management" + ], + "repo": "https://github.com/nickanderson/cfengine-delete-files", + "by": "https://github.com/nickanderson", + "version": "2.0.0", + "commit": "84cce7c5653b6a5f2b5a28ebb33c697ffc676dd4", + "steps": [ + "copy delete-files.cf services/cfbs/modules/delete-files/delete-files.cf", + "input delete-files/input.json def.json", + "bundles delete_files:delete_files", + "policy_files services/cfbs/modules/delete-files/delete-files.cf" + ], + "input": [ + { + "type": "list", + "variable": "files", + "namespace": "delete_files", + "bundle": "delete_files", + "label": "Files", + "subtype": [ + { + "key": "path", + "type": "string", + "label": "Path", + "question": "Path to file" + }, + { + "key": "why", + "type": "string", + "label": "Why", + "question": "Why should this file be deleted?", + "default": "Unknown" + } + ], + "while": "Specify another file you want deleted on your hosts?" + } + ] + }, + "clamav": { + "description": "Automates the installation, scanning, and reporting of ClamAV antivirus software.", + "tags": [ + "compliance", + "experimental", + "inventory", + "security" + ], + "repo": "https://github.com/aleksandrychev/cfengine-clamav", + "by": "https://github.com/aleksandrychev", + "version": "0.1.0", + "commit": "6ebb89d7d54966b4a8cdbe8d68e510e47086fba8", + "dependencies": [ + "compliance-report-imports" + ], + "steps": [ + "copy policy/main.cf services/cfbs/modules/clamav/main.cf", + "json cfbs/def.json def.json", + "input ./input.json def.json", + "copy ./compliance-report/compliance_report_clamav.json .no-distrib/compliance-report-definitions/compliance_report_clamav.json" + ], + "input": [ + { + "type": "string", + "variable": "max_age_days", + "namespace": "clamav", + "bundle": "globals", + "label": "Scan interval (days)", + "question": "How many days may an existing scan report age before a new clamscan run is forced?", + "default": "3" + }, + { + "type": "string", + "variable": "signature_max_age_days", + "namespace": "clamav", + "bundle": "globals", + "label": "Signature update interval (days)", + "question": "How many days may pass between freshclam signature updates?", + "default": "2" + }, + { + "type": "list", + "variable": "scan_target", + "namespace": "clamav", + "bundle": "globals", + "label": "Scan targets", + "subtype": { + "type": "string", + "label": "Directory", + "question": "Directory for clamscan to scan recursively (defaults: /home /root /tmp /var/tmp)" + }, + "while": "Add another directory to scan?" + }, + { + "type": "list", + "variable": "exclude_dirs", + "namespace": "clamav", + "bundle": "globals", + "label": "Excluded directories", + "subtype": { + "type": "string", + "label": "Directory", + "question": "Directory clamscan should skip, without trailing slash. Answering replaces the whole default list (/proc /sys /dev /run /var/lib/clamav /var/cfengine), so re-add any you want to keep" + }, + "while": "Add another exclusion?" + } + ] + }, + "promise-type-git": { + "description": "Promise type to manage git repos.", + "tags": [ + "supported", + "promise-type" + ], + "repo": "https://github.com/cfengine/modules", + "by": "https://github.com/tranchitella", + "version": "0.2.6", + "commit": "3055538003b5dc88c80547703368da33fd43a5a9", + "subdirectory": "promise-types/git", + "dependencies": [ + "library-for-promise-types-in-python" + ], + "steps": [ + "copy git.py modules/promises/", + "append enable.cf services/init.cf" + ] + }, + "library-for-promise-types-in-python": { + "description": "Library enabling promise types implemented in python.", + "tags": [ + "supported", + "library" + ], + "repo": "https://github.com/cfengine/modules", + "by": "https://github.com/cfengine", + "version": "0.3.1", + "commit": "679c6c0b18cdb517c7595b1701c3707f47106da5", + "subdirectory": "libraries/python", + "steps": [ + "copy cfengine_module_library.py modules/promises/cfengine_module_library.py", + "replace_version 2 0.0.0 modules/promises/cfengine_module_library.py", + "copy cfengine_module_library.py modules/promises/cfengine.py", + "replace_version 2 0.0.0 modules/promises/cfengine.py" + ] + }, + "git": { + "alias": "promise-type-git" + } + } +} \ No newline at end of file diff --git a/src/main/modules.ts b/src/main/modules.ts index cd9f426..32685eb 100644 --- a/src/main/modules.ts +++ b/src/main/modules.ts @@ -10,6 +10,10 @@ const INDEX_TIMEOUT_MS = 15_000; const cachePath = () => join(app.getPath('userData'), 'module-index.json'); +// End-to-end tests (development builds only): the index and details from a folder, not the internet. +const fixtures = () => (!app.isPackaged && process.env.CFPB_MODULE_DATA) || null; +const fixture = async (file: string): Promise => JSON.parse(await fs.readFile(join(fixtures()!, file), 'utf-8')); + interface CachedIndex { fetchedAt: string; index: Record; @@ -27,6 +31,7 @@ async function readCache(): Promise { } async function download(): Promise { + if (fixtures()) return { fetchedAt: new Date().toISOString(), index: ((await fixture('index.json')) as { index: Record }).index }; const response = await fetch(INDEX_URL, { signal: AbortSignal.timeout(INDEX_TIMEOUT_MS) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const body: unknown = await response.json(); @@ -60,6 +65,7 @@ let detailsRequest: Promise<{ error?: string; modules: Record | async function allDetails() { try { + if (fixtures()) return { modules: (await fixture('details.json')) as Record, offline: false }; const response = await fetch(DETAILS_URL, { signal: AbortSignal.timeout(INDEX_TIMEOUT_MS * 2) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const modules: unknown = await response.json(); From 37f934926ec551ebb5922acc33909c320f13a45d Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 18:20:11 +0300 Subject: [PATCH 12/23] Test runs build the project with its CFEngine Build modules and their input They built only masterfiles and the builder's own policy, so a project using modules or promise-type blocks failed with Undefined promise type. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 33 ++++++++++++++++++++++ python/cfpb_testenv.py | 12 ++++++++ python/tests/test_cfpb_backend.py | 47 ++++++++++++++++++++++++++++++- python/tests/test_testenv.py | 14 +++++++++ src/main/project.ts | 2 +- src/main/testenv.ts | 6 ++-- 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index 8e7ce91..9c33d96 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -331,6 +331,39 @@ def _run_cfbs(directory: str, argv: list[str], what: str) -> None: raise InitFailed(f"cfbs {argv[0]} failed: {errors[-1]}" if errors else f"cfbs {argv[0]} failed (exit {code})") +def add_build_modules(directory: str, modules: list) -> list[str]: + """Adds the project's CFEngine Build modules to the cfbs project in `directory` (cfbs add + brings their dependencies) and writes their input answers. Returns the names added.""" + names = [ + m["name"] for m in modules if isinstance(m, dict) and isinstance(m.get("name"), str) and not m.get("addedBy") + ] + if not all(INDEX_MODULE.match(name) for name in names): + raise InitFailed("Invalid module name") + if names: + _run_cfbs(directory, ["add", *names], ", ".join(names)) + write_module_inputs(directory, modules) + return names + + +def write_module_inputs(directory: str, modules: list) -> None: + """/input.json for each module with answers: its questions (from cfbs.json), each with a + `response`, as cfbs input writes them.""" + with open(os.path.join(directory, "cfbs.json"), encoding="utf-8") as file: + entries = {e.get("name"): e for e in json.load(file).get("build", []) if isinstance(e, dict)} + for module in modules: + name, answers = module.get("name"), module.get("input") + questions = entries.get(name, {}).get("input") + if not isinstance(answers, dict) or not isinstance(questions, list) or not INDEX_MODULE.match(name or ""): + continue + answered = [ + {**q, "response": answers[q["variable"]]} if isinstance(q, dict) and q.get("variable") in answers else q + for q in questions + ] + os.makedirs(os.path.join(directory, name), exist_ok=True) + with open(os.path.join(directory, name, "input.json"), "w", encoding="utf-8") as file: + file.write(json.dumps(answered, indent=2) + "\n") + + def modules_command() -> int: """Adds and removes CFEngine Build modules in a project's cfbs.json (with their dependencies, as cfbs does: added before the module, removed once nothing needs them).""" diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 261f335..a7c167b 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -842,6 +842,18 @@ def build_policy(content: dict, masterfiles: str, cache_dir: str, work: str) -> finally: shutil.rmtree(scratch, ignore_errors=True) shutil.copytree(base, work, dirs_exist_ok=True) + # The project's CFEngine Build modules, before its own policy (as a saved project has them). + modules = content.get("buildModules") or [] + if modules: + emit( + "step", + step="build", + message="Adding modules: " + ", ".join(m.get("name", "?") for m in modules if not m.get("addedBy")), + ) + try: + cfpb_backend.add_build_modules(work, modules) + except cfpb_backend.InitFailed as error: + raise RunnerError(str(error)) from error options = {"type": "policy-set", "name": "test", "description": "Test build", "git": False, "content": content} cfpb_backend._update_cfbs_json(work, options) emit("step", step="build", message="Building the policy set") diff --git a/python/tests/test_cfpb_backend.py b/python/tests/test_cfpb_backend.py index 252a261..c2b906d 100644 --- a/python/tests/test_cfpb_backend.py +++ b/python/tests/test_cfpb_backend.py @@ -13,7 +13,7 @@ import pytest import cfpb_backend -from cfpb_backend import main +from cfpb_backend import InitFailed, add_build_modules, main UNFORMATTED = 'bundle agent main\n{\n reports:\n "hello" ;\n}\n' FORMATTED = 'bundle agent main\n{\n reports:\n "hello";\n}\n' @@ -468,3 +468,48 @@ def test_modules_takes_a_project_and_module_names(tmp_path, monkeypatch: pytest. assert _run(["modules"], request(add=["../evil"]), monkeypatch)[0] == 2 code, stdout, _ = _run(["modules"], request(add=[], remove=[]), monkeypatch) assert code == 0 and json.loads(stdout) == {"added": [], "removed": []} + + +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +def test_add_build_modules_adds_them_with_cfbs_and_writes_their_answers(tmp_path, monkeypatch: pytest.MonkeyPatch): + import subprocess + + monkeypatch.setenv("CFBS_GLOBAL_DIR", str(tmp_path / "cfbs-global")) + # A module in a local git repository, listed by a custom index: cfbs adds it offline. + repo = tmp_path / "tiny-module" + repo.mkdir() + (repo / "policy.cf").write_text("bundle agent tiny_main {}\n") + git = lambda *args: subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + git("init", "-q") + git("add", ".") + git("-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-qm", "Tiny") + question = { + "type": "string", + "variable": "greeting", + "namespace": "tiny", + "bundle": "main", + "label": "Greeting", + "question": "Say what?", + } + entry = {"description": "Tiny", "tags": [], "repo": f"file://{repo}", "by": "x", "version": "1.0.0"} + entry |= { + "commit": git("rev-parse", "HEAD"), + "steps": ["copy policy.cf services/tiny/policy.cf"], + "input": [question], + } + project = tmp_path / "project" + project.mkdir() + (project / "index.json").write_text(json.dumps({"type": "index", "index": {"tiny-module": entry}})) + (project / "cfbs.json").write_text( + json.dumps({"name": "t", "description": "t", "type": "policy-set", "index": "./index.json", "build": []}) + ) + + added = add_build_modules(str(project), [{"name": "tiny-module", "input": {"greeting": "hi"}}]) + + assert added == ["tiny-module"] + assert [e["name"] for e in json.loads((project / "cfbs.json").read_text())["build"]] == ["tiny-module"] + assert json.loads((project / "tiny-module" / "input.json").read_text()) == [{**question, "response": "hi"}] + with pytest.raises(InitFailed, match="Invalid module name"): + add_build_modules(str(project), [{"name": "../evil"}]) diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index b261849..20eb546 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -600,3 +600,17 @@ def test_a_policy_the_hub_refuses_is_traced_to_its_block(): assert (problem["block"], problem["fileId"], problem["file"], problem["line"]) == ("mark", "f", "./site.cf", line) assert problem["message"] == "Attempted to give a scalar to a non-scalar type" + + +def test_a_test_build_adds_the_project_modules_before_its_policy(tmp_path: Path, monkeypatch): + (tmp_path / "masterfiles" / "3.27.1").mkdir(parents=True) + (tmp_path / "masterfiles" / "3.27.1" / "cfbs.json").write_text("{}") + calls = [] + monkeypatch.setattr(cfpb_backend, "add_build_modules", lambda work, modules: calls.append(("add", modules))) + monkeypatch.setattr( + cfpb_backend, "_update_cfbs_json", lambda *args: (calls.append(("policy",)), _stop_building())[1] + ) + modules = [{"name": "delete-files", "input": {"files": []}}, {"name": "library-x", "addedBy": "promise-type-x"}] + with pytest.raises(_Built): + cfpb_testenv.build_policy({"buildModules": modules}, "3.27.1", str(tmp_path), str(tmp_path / "work")) + assert calls == [("add", modules), ("policy",)] diff --git a/src/main/project.ts b/src/main/project.ts index 1099d5e..0a7b0cf 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -174,7 +174,7 @@ export function checkedBuilderModules(modules: unknown[], provided: Record +export const isBuildModule = (module: unknown) => isRecord(module) && typeof module.name === 'string' && INDEX_MODULE.test(module.name) && diff --git a/src/main/testenv.ts b/src/main/testenv.ts index 3bbfef9..fc53686 100644 --- a/src/main/testenv.ts +++ b/src/main/testenv.ts @@ -2,7 +2,7 @@ import { type IpcMainInvokeEvent, type WebFrameMain, app, ipcMain } from 'electr import { isAbsolute, join } from 'path'; import { type SidecarStream, startSidecarStream, testEnvQuery } from './backend'; -import { isInKnownProject } from './project'; +import { isBuildModule, isInKnownProject } from './project'; /** * Test environments (the Test Results & Logs tab): Docker hosts managed by the @@ -36,7 +36,9 @@ const isBuilderContent = (content: unknown) => module.name.startsWith('./') && Array.isArray(module.steps) && module.steps.every(step => typeof step === 'string' && BUILDER_STEP.test(step)) - ); + ) && + // Its CFEngine Build modules: cfbs adds them to the test build. + (content.buildModules === undefined || (Array.isArray(content.buildModules) && content.buildModules.every(isBuildModule))); // What the sidecar gets: the renderer's request, checked, plus where builds and masterfiles are cached. async function sidecarRequest(action: string, request: unknown): Promise> { From 3828f29cea573e7ba6f84148db4d1759196536e0 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 18:20:24 +0300 Subject: [PATCH 13/23] A promise-type block's note is a policy comment, not a comment attribute Promise modules refuse it: Unknown attribute 'comment' for git promise. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_compiler.py | 19 +++++++++++++++---- python/tests/test_compiler.py | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 0a22387..74629fe 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -540,10 +540,19 @@ def run_interval(block: dict, descriptor: dict) -> list[str]: return [f'action => default:if_elapsed("{minutes}")'] -def noted(block: dict) -> list[str]: - """A block's note, as its promises' comment (one line: CFEngine prints it in reports).""" +# The promise types CFEngine itself runs; others come from promise modules (git, http…). +BUILT_IN_TYPES = {"vars", "classes", "users", "files", "packages", "methods", "processes", "services", "commands"} +BUILT_IN_TYPES |= {"storage", "reports", "guest_environments", "databases", "defaults", "meta"} + + +def noted(block: dict, promise_type: str | None = None) -> list[str]: + """A block's note, as its promises' comment (one line: CFEngine prints it in reports). A + promise module refuses a comment attribute (Unknown attribute 'comment'): its note is a + policy comment above the promise instead.""" note = one_line(block.get("note") or "") - return [f"comment => {quote(note)}"] if note else [] + if not note or (promise_type and promise_type not in BUILT_IN_TYPES): + return [] + return [f"comment => {quote(note)}"] def one_line(text: str) -> str: @@ -945,13 +954,15 @@ def block_lines(self, block: dict, names: dict[str, str], edges: list[dict], own variables, promise_type, promiser, attributes = parts self.chunks.append((block["instanceId"], label)) body = [f" # {label}"] + if promise_type not in BUILT_IN_TYPES and one_line(block.get("note") or ""): + body.append(f" # Note: {one_line(block['note'])}") if variables: body += [" vars:", *guarded, *[f" {line}" for line in variables]] lines = promise( promiser, [ *attributes, - *noted(block), + *noted(block, promise_type), *run_interval(block, self.descriptor(block)), *self.gating(block, names, edges), ], diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 8a9f5c1..9c32070 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -728,3 +728,20 @@ def test_a_custom_promise_takes_attribute_rows_as_strings_or_quoted_lists(): assert "Skipped" in _custom_promise("json", "not a row") assert "set by the block itself" in _custom_promise("json", "classes = x") assert "isn't a promise type name" in _custom_promise("files: x", "") + + +def test_a_promise_modules_block_note_is_a_policy_comment(): + # Promise modules refuse a comment attribute: "Unknown attribute 'comment' for git promise". + block = { + "instanceId": "g", + "blockId": "git-checkout", + "label": "Clone", + "note": "Why we clone", + "params": {"destination": "/srv/a", "repository": "https://x/y", "version": "main"}, + } + project = { + "files": [{"id": "f", "name": "S", "namespace": "s", "path": "./s.cf", "blocks": [block], "order": ["g"]}] + } + policy = compile_project(project)["./s.cf"] + assert " # Note: Why we clone\n git:" in policy + assert "comment =>" not in policy From a5979df3487ffabaf6ca2dded6750c474415389e Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 18:20:24 +0300 Subject: [PATCH 14/23] Git Checkout asks for the branch, tag or commit Without one the git module clones --branch HEAD, which git refuses. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/git-checkout.json | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/blocks/git-checkout.json b/blocks/git-checkout.json index 607b3cf..29f17f4 100644 --- a/blocks/git-checkout.json +++ b/blocks/git-checkout.json @@ -14,10 +14,7 @@ "promiser": "{{destination}}", "attributes": { "repository": "{{repository}}", - "version": { - "if_set": "version", - "value": "{{version}}" - }, + "version": "{{version}}", "update": "{{update}}", "force": "{{force}}", "depth": { @@ -47,9 +44,8 @@ "name": "version", "label": "Branch, tag or commit", "type": "string", - "required": false, - "default": "", - "help": "Empty: the remote's default branch." + "required": true, + "help": "E.g. main or master: the git module can't follow the remote's default branch unnamed." }, { "name": "update", From c5569fa76f05a22c245d1e9ddf1ce68b69cd1451 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 18:20:48 +0300 Subject: [PATCH 15/23] Module search matches every word of the search, in any order Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/modules/catalog.test.ts | 2 ++ src/renderer/src/modules/catalog.ts | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/modules/catalog.test.ts b/src/renderer/src/modules/catalog.test.ts index 895e981..0e713f8 100644 --- a/src/renderer/src/modules/catalog.test.ts +++ b/src/renderer/src/modules/catalog.test.ts @@ -27,6 +27,8 @@ describe('the module catalog', () => { expect(names(filterCatalog(modules, { ...all, search: 'sshd' }))).toEqual(['library-sshd-config']); expect(filterCatalog(modules, { ...all, search: 'old' })).toEqual([{ module: byName.get('promise-type-git'), alias: 'git-old' }]); expect(names(filterCatalog(modules, { ...all, supportedOnly: true }))).toEqual(['delete-files']); + expect(names(filterCatalog(modules, { ...all, search: 'files deletes' }))).toEqual(['delete-files']); + expect(filterCatalog(modules, { ...all, search: 'delete git' })).toEqual([]); }); it('adds a module with its dependencies, and removes those nothing else needs', () => { diff --git a/src/renderer/src/modules/catalog.ts b/src/renderer/src/modules/catalog.ts index 80dc2f9..9c74198 100644 --- a/src/renderer/src/modules/catalog.ts +++ b/src/renderer/src/modules/catalog.ts @@ -125,8 +125,11 @@ export interface CatalogMatch { module: CatalogModule; } -const matchesTerm = (module: CatalogModule, term: string) => - module.name.toLowerCase().includes(term) || module.description.toLowerCase().includes(term) || module.tags.some(tag => tag.toLowerCase().includes(term)); +// Every word of the search, in any order, in the name, description or tags ("delete files"). +const matchesTerm = (module: CatalogModule, term: string) => { + const text = [module.name, module.description, ...module.tags].join(' ').toLowerCase(); + return term.split(/\s+/).every(word => text.includes(word)); +}; // Tag chip, Supported and libraries; the search is matched by filterCatalog. function keeps(module: CatalogModule, { supportedOnly, tag }: CatalogFilter, term: string, showLibraries: boolean) { From 941bf41baad097d74000248d68579b47b53f9226 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 18:21:23 +0300 Subject: [PATCH 16/23] Custom Promise reads attribute rows as CFEngine writes them too name => "value", with a trailing comma, compiled to a value starting with > and wrapped in escaped quotes; a quoted value is now the text inside the quotes. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/custom-promise.json | 2 +- python/cfpb_compiler.py | 12 ++++++++++-- python/tests/test_compiler.py | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/blocks/custom-promise.json b/blocks/custom-promise.json index 560a5fd..e2c46d3 100644 --- a/blocks/custom-promise.json +++ b/blocks/custom-promise.json @@ -88,7 +88,7 @@ "type": "text", "required": false, "default": "", - "help": "One per line, name = value. A list: name = { \"a\", \"b\" }." + "help": "One per line: name = value (or name => \"value\"). A list: name = { \"a\", \"b\" }." } ] } diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 74629fe..efda9b2 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -400,7 +400,9 @@ def unquote(string: str) -> str: PROMISE_TYPE = re.compile(r"^[a-z][a-z0-9_]*$") -ATTRIBUTE_ROW = re.compile(r"^\s*([a-z][a-z0-9_]*)\s*=\s*(.*?)\s*$") +# name = value, or as CFEngine writes it (name => value,): the separator and a trailing , or ; are optional sugar. +ATTRIBUTE_ROW = re.compile(r"^\s*([a-z][a-z0-9_]*)\s*=>?\s*(.*?)\s*[,;]?\s*$") +QUOTED = re.compile(r'^"((?:[^"\\]|\\.)*)"$') # A list of quoted strings, written as in CFEngine: { "a", "b" }. QUOTED_LIST = re.compile(r'^\{\s*(?:"(?:[^"\\]|\\.)*"\s*(?:,\s*"(?:[^"\\]|\\.)*"\s*)*)?\}$') # Handled by the agent and set by the block itself (condition, arrows, How often, note). @@ -439,7 +441,13 @@ def attribute_rows(text: str) -> list[str]: name, value = found.groups() if name in AGENT_ATTRIBUTES: raise Skip(f"{name} is set by the block itself (condition, arrows, How often, note)") - lines.append(f"{name} => {value if QUOTED_LIST.match(value) else quote(value)}") + if QUOTED_LIST.match(value): + lines.append(f"{name} => {value}") + continue + # A quoted string is the text inside the quotes ("shop" is shop), escapes undone. + quoted = QUOTED.match(value) + text = re.sub(r'\\(["\\])', r"\1", quoted.group(1)) if quoted else value + lines.append(f"{name} => {quote(text)}") return lines diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 9c32070..61981c2 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -725,6 +725,9 @@ def test_a_custom_promise_takes_attribute_rows_as_strings_or_quoted_lists(): assert ' json:\n "/etc/app.json"\n field => "name",\n list => { "a", "b" },' in policy # Anything but a list of quoted strings is one string, so it can't break out of the promise. assert 'odd => "\\"}; files: \\"/x\\" delete => tidy; #"' in policy + # As CFEngine writes them: => and a trailing comma, a quoted string. + written = _custom_promise("json", 'string => "shop",\nother = "say \\"hi\\""\nbare = shop') + assert 'string => "shop",' in written and 'other => "say \\"hi\\"",' in written and 'bare => "shop";' in written assert "Skipped" in _custom_promise("json", "not a row") assert "set by the block itself" in _custom_promise("json", "classes = x") assert "isn't a promise type name" in _custom_promise("files: x", "") From 7bc20a524b1389d3038e309ed1e4e8019f136842 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 18:55:48 +0300 Subject: [PATCH 17/23] Module tools confirm an answer and read a README's tables as rows set_module_input says what it stored and which questions are left, and get_module's README text keeps a table's cells as a | b instead of runs of tabs and line breaks. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/mcpTools/guide.ts | 2 +- src/renderer/src/mcp/moduleTools.test.ts | 15 +++++++++-- src/renderer/src/mcp/moduleTools.ts | 34 +++++++++++++++--------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/main/mcpTools/guide.ts b/src/main/mcpTools/guide.ts index a96b708..3460de8 100644 --- a/src/main/mcpTools/guide.ts +++ b/src/main/mcpTools/guide.ts @@ -17,7 +17,7 @@ Policy Builder builds CFEngine policy from blocks on a canvas. You change the pr 1. **begin_work** with one sentence on what you're about to do. The user sees it, and the editor is locked so they don't edit at the same time. Always pair it with **end_work**. 2. **get_project_status** / **get_project_overview**: what's open, its files and their namespaces, what's selected. Open or create a project first if none is (create_project, open_project). -3. **Plan from the block catalog**: list_block_types, then get_block_type for each type you'll use. Use its parameter names, options and required fields exactly; values are checked like the Properties form checks them. +3. **Plan from the block catalog**: list_block_types, then get_block_type for each type you'll use (\`{ "blockType": "" }\`). Use its parameter names, options and required fields exactly; values are checked like the Properties form checks them. 4. **Edit**: add_block, update_block, connect; variables and classes with add_entry; transformers; conditions. Each call is one undo step for the user. add_block takes a condition, a group and an arrow into the block (after) in the same call; add_blocks adds a list of blocks (chained by index) in one call, all or nothing; add_file takes a description and condition. 5. **tidy_file** after adding or connecting several blocks, so the canvas reads top to bottom in run order. 6. **Check**: get_warnings (empty required parameters, values a parameter doesn't take, and notCompiled: blocks or entries the policy leaves out, with why), then get_generated_policy for the file you changed: read the promises you meant to write. diff --git a/src/renderer/src/mcp/moduleTools.test.ts b/src/renderer/src/mcp/moduleTools.test.ts index 62850d0..e250bae 100644 --- a/src/renderer/src/mcp/moduleTools.test.ts +++ b/src/renderer/src/mcp/moduleTools.test.ts @@ -42,7 +42,11 @@ beforeEach(() => { installApi({ getModuleIndex: async () => ({ index: INDEX, fetchedAt: '2026-10-16T00:00:00Z', offline: false }), getModuleDetails: async () => ({ - details: { readme: '

Use

Lists files.

', author: { name: 'Nick' } }, + details: { + readme: + '

Use

Lists files.

\n\t
NameType
stringtext
', + author: { name: 'Nick' } + }, offline: false }) }); @@ -65,6 +69,8 @@ describe('module tools', () => { expect(module.input).toMatchObject([{ variable: 'files', type: 'list', fields: [{ key: 'path' }, { key: 'why', default: 'Unknown' }] }]); expect(module.readme).toContain('Use'); expect(module.readme).toContain('files (https://x.test/y)'); + expect(module.readme).toContain('Name | Type\n'); + expect(module.readme).toContain('string | text'); expect(await refusal('get_module', { name: 'nope' })).toMatch(/No module nope/); }); @@ -72,7 +78,12 @@ describe('module tools', () => { expect(await call('add_module', { name: 'promise-type-git' })).toMatchObject({ added: ['promise-type-git', 'library-for-promise-types-in-python'] }); expect(await call('add_module', { name: 'delete-files' })).toMatchObject({ input: expect.stringContaining('set_module_input') }); expect(await refusal('set_module_input', { name: 'delete-files', variable: 'files', response: [{ path: '/a', reason: 'x' }] })).toMatch(/no field reason/); - expect(await call('set_module_input', { name: 'delete-files', variable: 'files', response: [{ path: '/a' }] })).toEqual({}); + expect(await call('set_module_input', { name: 'delete-files', variable: 'files', response: [{ path: '/a' }] })).toEqual({ + name: 'delete-files', + variable: 'files', + answer: [{ path: '/a', why: 'Unknown' }], + complete: true + }); expect(store.getState().modules.find(m => m.name === 'delete-files')?.input).toEqual({ files: [{ path: '/a', why: 'Unknown' }] }); expect((await call('get_module', { name: 'delete-files' })).project).toEqual({ usedBy: [] }); store.dispatch(undone()); diff --git a/src/renderer/src/mcp/moduleTools.ts b/src/renderer/src/mcp/moduleTools.ts index b2af042..34067c6 100644 --- a/src/renderer/src/mcp/moduleTools.ts +++ b/src/renderer/src/mcp/moduleTools.ts @@ -40,18 +40,27 @@ function policySet(state: RootState) { const usedBy = (state: RootState, name: string) => state.canvas.filter(block => requiredModule(block) === name).map(block => block.label); -// The README as plain text, headings and list items on lines of their own. -function readmeText(nodes: ReadmeNode[]): string { - const blocks = new Set(['p', 'div', 'pre', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'dt', 'dd', 'blockquote', 'figure', 'br', 'hr']); - return nodes - .map(node => { - if (typeof node === 'string') return node; - const inner = readmeText(node.children); - if (node.tag === 'img') return `[image: ${node.alt ?? ''}]`; - if (node.tag === 'a' && node.href && inner.trim() && inner.trim() !== node.href) return `${inner} (${node.href})`; - return blocks.has(node.tag) ? `\n${node.tag === 'li' ? '- ' : ''}${inner}\n` : inner; - }) +// The README as plain text: headings, paragraphs and list items on lines of their own, a table's +// rows as "a | b"; the markup's own whitespace collapses, except in code blocks. +const BLOCKS = new Set(['p', 'div', 'pre', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'dt', 'dd', 'blockquote', 'figure', 'br', 'hr', 'table']); + +function readmeText(nodes: ReadmeNode[], pre = false): string { + const parts = nodes.map(node => { + if (typeof node === 'string') return pre ? node : node.replace(/\s+/g, ' '); + if (node.tag === 'img') return `[image: ${node.alt ?? ''}]`; + const inner = readmeText(node.children, pre || node.tag === 'pre'); + if (node.tag === 'a' && node.href && inner.trim() && inner.trim() !== node.href) return `${inner} (${node.href})`; + if (node.tag === 'td' || node.tag === 'th') return `${inner.trim()} | `; + if (node.tag === 'tr') + return `\n${inner + .trim() + .replace(/\s*\|$/, '') + .replace(/ {2,}/g, ' ')}\n`; + return BLOCKS.has(node.tag) ? `\n${node.tag === 'li' ? '- ' : ''}${inner.trim()}\n` : inner; + }); + return parts .join('') + .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n'); } @@ -194,7 +203,8 @@ async function setModuleInput(env: ToolEnv, input: Input) { if (!isSupported(question)) throw new ToolError(`${variable}'s input type isn’t supported in the builder: answer it with cfbs input`); env.dispatch(moduleInputChanged({ name: module.name, variable, response: checkedResponse(question, input.response) })); const added = env.getState().modules.find(item => item.name === module.name); - return { needsInput: needsInput(module.input, added?.input) || undefined }; + const unanswered = module.input.filter(item => item.variable && !isAnswered(item, added?.input?.[item.variable])).map(item => item.variable); + return { name: module.name, variable, answer: added?.input?.[variable], ...(unanswered.length ? { unanswered } : { complete: true }) }; } export const MODULE_TOOLS: Record = { From 1f402d8aaf3551e67242523a6147c4f429a1d1c0 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 19:01:27 +0300 Subject: [PATCH 18/23] A block's module shows the dependencies cfbs will add with it on save Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/modules/Modules.test.tsx | 4 +++- .../src/components/modules/ModulesView.tsx | 24 ++++++++++++++++--- src/renderer/src/mcp/canvasTools.ts | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/modules/Modules.test.tsx b/src/renderer/src/components/modules/Modules.test.tsx index d53fae5..c5a7f10 100644 --- a/src/renderer/src/components/modules/Modules.test.tsx +++ b/src/renderer/src/components/modules/Modules.test.tsx @@ -146,7 +146,9 @@ describe('modules from the Blocks palette', () => { expect(store.getState().modules).toEqual([{ name: 'promise-type-git' }]); fireEvent.click(within(sideBar()).getByRole('button', { name: 'Modules' })); - fireEvent.click(await screen.findByText('promise-type-git')); + // Its library, which cfbs adds on save, shows under it already. + expect(await screen.findByText('added for promise-type-git, on save')).toBeInTheDocument(); + fireEvent.click(screen.getAllByText('promise-type-git')[0]); expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled(); expect(screen.getByText(/Used by Git Checkout/)).toBeInTheDocument(); }); diff --git a/src/renderer/src/components/modules/ModulesView.tsx b/src/renderer/src/components/modules/ModulesView.tsx index 96db3dc..32ff9c0 100644 --- a/src/renderer/src/components/modules/ModulesView.tsx +++ b/src/renderer/src/components/modules/ModulesView.tsx @@ -19,7 +19,7 @@ import { Typography } from '@mui/material'; -import { type CatalogFilter, type CatalogModule, TAG_CHIPS, filterCatalog } from '../../modules/catalog'; +import { type CatalogFilter, type CatalogModule, TAG_CHIPS, filterCatalog, modulesToAdd } from '../../modules/catalog'; import type { ModuleCatalog } from '../../modules/useModuleCatalog'; import type { ProjectModule } from '../../store/modulesSlice'; import { ModuleRow } from './ModuleRow'; @@ -99,7 +99,19 @@ export function ModulesView({ // The project's modules in build order, dependencies under the module they came with. const inProject = useMemo(() => { - const entries = projectModules.map(module => ({ module, entry: catalog.byName.get(module.name) ?? unknownModule(module) })); + // Dependencies cfbs adds on save that the project doesn't list yet (a block's module brings none). + const pending = projectModules.flatMap(module => + module.addedBy + ? [] + : modulesToAdd( + catalog.byName, + module.name, + projectModules.filter(other => other !== module) + ) + .slice(1) + .map(dependency => ({ ...dependency, pending: true })) + ); + const entries = [...projectModules, ...pending].map(module => ({ module, entry: catalog.byName.get(module.name) ?? unknownModule(module) })); const kept = filtering ? new Set( filterCatalog( @@ -208,7 +220,13 @@ export function ModulesView({ ) : ( inProject.map(({ module, entry }) => ( - + )) )} diff --git a/src/renderer/src/mcp/canvasTools.ts b/src/renderer/src/mcp/canvasTools.ts index df59dfa..33c79e8 100644 --- a/src/renderer/src/mcp/canvasTools.ts +++ b/src/renderer/src/mcp/canvasTools.ts @@ -64,7 +64,7 @@ function projectOverview(state: RootState, env: CanvasEnv) { const selected = state.canvas.find(block => block.instanceId === env.selectedInstanceId); return { project: state.project?.name ?? null, - // CFEngine Build modules, in build order (get_module for one). + // CFEngine Build modules, in build order (get_module for one); saving adds their dependencies too. modules: state.modules.map(module => ({ name: module.name, addedFor: module.addedBy })), files: files.map(file => { const counts: Record = {}; From 5a7fcb7b59a8e9530c8dc98a2a0a417e1f152b6b Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 19:03:01 +0300 Subject: [PATCH 19/23] A promise module's error is traced to its block by the file and line it names Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 11 +++++++++++ python/tests/test_testenv.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index a7c167b..d6242b0 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -964,6 +964,10 @@ def _setup_code(engine, hub, hub_host: dict) -> None: # cf-agent's own location for what it's reporting on, e.g. # "info: Promise belongs to bundle 'x' in file '/var/cfengine/inputs/services/cfbs/security.cf' near line 74". BELONGS = re.compile(r"Promise belongs to bundle '([^']+)' in file '([^']+)' near line (\d+)") +# A promise module's error names its place itself: "… (/var/cfengine/inputs/services/cfbs/site.cf:30)". +IN_FILE = re.compile(r"(/[^\s():']+\.cf):(\d+)") +# A parser's error: "/var/cfengine/inputs/services/cfbs/site.cf:30:3: error: Undefined promise type 'git'". +PARSE_ERROR = re.compile(r"^(/\S+\.cf):(\d+):\d+: error: (.*)$") # Roll-ups of an error already reported on its own line. ROLLUP = re.compile(r"Method '[^']+' failed in some repairs|Errors encountered when actuating|Not all promises") INPUTS = "/var/cfengine/inputs/services/cfbs/" @@ -1060,6 +1064,9 @@ def find_problems(lines: list[str], source_map: dict, block_files: dict, policie location: tuple[str, str, int] | None = None for raw in lines: line = raw.strip() + parsed = PARSE_ERROR.match(line) + if parsed: + line = f"error: {parsed.group(3)} ({parsed.group(1)}:{parsed.group(2)})" belongs = BELONGS.search(line) if belongs: location = (belongs.group(1), belongs.group(2), int(belongs.group(3))) @@ -1073,6 +1080,10 @@ def find_problems(lines: list[str], source_map: dict, block_files: dict, policie message = line[len("error:") :].strip() if ROLLUP.search(message) and problems: continue + # Its own place: the info lines before it are another promise's. + named = IN_FILE.search(message) if not location else None + if named: + location, context = (None, named.group(1), int(named.group(2))), [] command = COMMAND_FAILED.search(message) if not location and policies else None found = _promiser_at(command.group(1), policies) if command else None if found: diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index 20eb546..f4a7c9f 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -614,3 +614,19 @@ def test_a_test_build_adds_the_project_modules_before_its_policy(tmp_path: Path, with pytest.raises(_Built): cfpb_testenv.build_policy({"buildModules": modules}, "3.27.1", str(tmp_path), str(tmp_path / "work")) assert calls == [("add", modules), ("policy",)] + + +def test_a_promise_modules_error_is_traced_by_the_place_it_names(): + lines = [ + " info: Successfully installed package 'git'", + " error: Unknown attribute 'comment' for git promise with promiser '/srv/app' (/var/cfengine/inputs/services/cfbs/site.cf:30)", + "/var/cfengine/inputs/services/cfbs/site.cf:56:3: error: Undefined promise type 'json'", + ] + source_map = {"./site.cf": {"clone": [[25, 35]], "config": [[50, 60]]}} + + first, second = cfpb_testenv.find_problems(lines, source_map, {"clone": "f", "config": "f"}) + + # The info line before it was another promise's. + assert (first["block"], first["line"], first["cause"]) == ("clone", 30, []) + assert (second["block"], second["line"]) == ("config", 56) + assert second["message"].startswith("Undefined promise type 'json'") From 8ea2777684f56d79683860c7cda98f10e197e4cf Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 19:17:51 +0300 Subject: [PATCH 20/23] AI agents see the dependencies saving adds, and clearer module wording The project overview lists the dependencies cfbs adds on save for a block's module; the note, Custom Promise and test environment texts say what really happens. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/custom-promise.json | 2 +- src/main/mcpTools/canvas.ts | 2 +- src/main/mcpTools/testing.ts | 2 +- src/renderer/src/mcp/canvasTools.ts | 7 ++++++- src/renderer/src/mcp/moduleTools.test.ts | 6 ++++++ src/renderer/src/mcp/moduleTools.ts | 17 +++++++++++++++++ 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/blocks/custom-promise.json b/blocks/custom-promise.json index e2c46d3..fc566c3 100644 --- a/blocks/custom-promise.json +++ b/blocks/custom-promise.json @@ -73,7 +73,7 @@ "label": "ansible" } ], - "help": "Adds its module (promise-type-…) to the project. Its README on the module's page lists the attributes." + "help": "Adds its module (promise-type-…) to the project. Its README lists the attributes: on the module's page, or get_module for AI agents." }, { "name": "promiser", diff --git a/src/main/mcpTools/canvas.ts b/src/main/mcpTools/canvas.ts index ea71f01..1742294 100644 --- a/src/main/mcpTools/canvas.ts +++ b/src/main/mcpTools/canvas.ts @@ -16,7 +16,7 @@ const condition = z const note = z .string() .describe( - 'Why the block is there, or a decision behind it (e.g. “daily, not weekly: the old run took too long”): shown in Properties, compiled to the promise’s comment' + 'Why the block is there, or a decision behind it (e.g. “daily, not weekly: the old run took too long”): shown in Properties, compiled to the promise’s comment (a # Note: line above a module’s promise type, which refuses comment)' ); const runInterval = z diff --git a/src/main/mcpTools/testing.ts b/src/main/mcpTools/testing.ts index f99908f..5df6ed6 100644 --- a/src/main/mcpTools/testing.ts +++ b/src/main/mcpTools/testing.ts @@ -66,7 +66,7 @@ export function registerTestTools(server: McpServer, answer: Answer) { 'set_test_environment', { description: - 'Creates a test environment (no environmentId) or changes one; only the fields given change. `hosts` replaces the host list: hosts matched by id or name are kept (with their containers), others are added or removed. Platforms are checked against list_test_platforms. A new environment defaults to Community, latest, x86_64 and one ubuntu-22 host named hub. One undo step; nothing runs. Returns the environment’s id.', + 'Creates a test environment (no environmentId) or changes one; only the fields given change. `hosts` replaces the host list: hosts matched by id or name are kept (with their containers), others are added or removed. Platforms are checked against list_test_platforms. A new environment defaults to Community, latest, x86_64 and one ubuntu-22 host named hub. Saved with the project (so it leaves unsaved changes); one undo step; nothing runs. Returns the environment’s id.', inputSchema: { environmentId: z.string().optional().describe('The environment to change; omit to create one'), name: z.string().optional(), diff --git a/src/renderer/src/mcp/canvasTools.ts b/src/renderer/src/mcp/canvasTools.ts index 33c79e8..243f3d6 100644 --- a/src/renderer/src/mcp/canvasTools.ts +++ b/src/renderer/src/mcp/canvasTools.ts @@ -28,6 +28,7 @@ import type { BlockOutcome } from '../store/edgesSlice/types'; import { changeMembership } from '../store/groupArrows'; import { groupFramesFitted } from '../store/groupsSlice'; import { inOneStep } from '../store/history'; +import { dependenciesOnSave } from './moduleTools'; import { asRecord, checkedParams, parameterView, valueProblem } from './params'; import { type CanvasEnv, type Input, type Tool, ToolError, canvasOf, optionalStr, str } from './shared'; @@ -462,7 +463,11 @@ function tidyFile(env: CanvasEnv, input: Input) { } export const CANVAS_TOOLS: Record = { - get_project_overview: env => projectOverview(env.getState(), canvasOf(env)), + get_project_overview: async env => { + const overview = projectOverview(env.getState(), canvasOf(env)); + const pending = await dependenciesOnSave(env.getState()); + return pending.length ? { ...overview, modulesAddedOnSave: pending } : overview; + }, get_file: (env, input) => fileDetail(env.getState(), str(input, 'fileId')), list_block_types: () => blockDescriptors.map(({ id, name, category, description, requires_module }) => ({ id, name, category, description, requiresModule: requires_module })), diff --git a/src/renderer/src/mcp/moduleTools.test.ts b/src/renderer/src/mcp/moduleTools.test.ts index e250bae..951a450 100644 --- a/src/renderer/src/mcp/moduleTools.test.ts +++ b/src/renderer/src/mcp/moduleTools.test.ts @@ -2,6 +2,7 @@ import { undone } from '../store/history'; import { projectCreated, projectTypeChanged } from '../store/projectSlice'; import { type TestStore, addBlock, addFile, makeStore } from '../store/test/storeTestUtils'; import { installApi, uninstallApi } from '../test/render'; +import { dependenciesOnSave } from './moduleTools'; import type { SessionTools, ToolEnv } from './shared'; import { answerTool } from './tools'; @@ -98,6 +99,11 @@ describe('module tools', () => { expect(await refusal('remove_module', { name: 'library-for-promise-types-in-python' })).toMatch(/came with promise-type-git/); }); + it('name the dependencies saving will add for a block’s module', async () => { + addBlock(store, addFile(store), { blockId: 'git-checkout' }); + expect(await dependenciesOnSave(store.getState())).toEqual([{ name: 'library-for-promise-types-in-python', addedFor: 'promise-type-git' }]); + }); + it('refuse a module project', async () => { store.dispatch(projectTypeChanged({ type: 'module' })); expect(await refusal('add_module', { name: 'delete-files' })).toMatch(/policy-set projects/); diff --git a/src/renderer/src/mcp/moduleTools.ts b/src/renderer/src/mcp/moduleTools.ts index 34067c6..ceb0336 100644 --- a/src/renderer/src/mcp/moduleTools.ts +++ b/src/renderer/src/mcp/moduleTools.ts @@ -25,6 +25,23 @@ function catalog() { return catalogRequest; } +/** The dependencies saving will add (cfbs add brings them) that the project doesn't list yet: a block's module. */ +export async function dependenciesOnSave(state: RootState): Promise<{ addedFor: string; name: string }[]> { + const known = await catalog().catch(() => null); + if (!known) return []; + return state.modules.flatMap(module => + module.addedBy + ? [] + : modulesToAdd( + known.byName, + module.name, + state.modules.filter(other => other !== module) + ) + .slice(1) + .map(dependency => ({ name: dependency.name, addedFor: module.name })) + ); +} + async function moduleOf(name: string) { const { byName } = await catalog(); const module = byName.get(name); From 0147c838e6ad4cc4d5f59dd1c0c1b94938a9306c Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 19:34:55 +0300 Subject: [PATCH 21/23] Promise-type blocks advise installing python3, with a button that adds it The advice shows in Properties and get_warnings until an Install Package block brings python3; the button adds one above the block with an arrow into it, as one undo step. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/mcpTools/guide.ts | 2 +- src/renderer/src/blocks/initialParams.ts | 8 +++++ src/renderer/src/canvas/advice.test.ts | 18 +++++++++- src/renderer/src/canvas/advice.ts | 21 ++++++++++-- .../src/components/PropertiesPanel.tsx | 15 +++++++- .../src/components/modules/Modules.test.tsx | 15 ++++++++ src/renderer/src/mcp/canvasTools.ts | 2 +- src/renderer/src/pages/ProjectView.tsx | 13 ++++--- src/renderer/src/pages/usePythonAdvice.ts | 34 +++++++++++++++++++ 9 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/blocks/initialParams.ts create mode 100644 src/renderer/src/pages/usePythonAdvice.ts diff --git a/src/main/mcpTools/guide.ts b/src/main/mcpTools/guide.ts index 3460de8..6359b9d 100644 --- a/src/main/mcpTools/guide.ts +++ b/src/main/mcpTools/guide.ts @@ -63,7 +63,7 @@ const blocks = `# Blocks, arrows and groups - Modules run before the project's own policy, in their build order; they're not blocks and have no arrows. ## Blocks from CFEngine Build modules -- Git Checkout (git-checkout), Systemd Unit (systemd-unit), HTTP Request (http-request) and Run Ansible Playbook (ansible-playbook) are custom promise types (git:, systemd:, http:, ansible:). Adding one adds its module (promise-type-git…) to the project; saving installs it with cfbs (needs the internet once). Hosts need python3, and git or Ansible for those blocks. +- Git Checkout (git-checkout), Systemd Unit (systemd-unit), HTTP Request (http-request) and Run Ansible Playbook (ansible-playbook) are custom promise types (git:, systemd:, http:, ansible:). Adding one adds its module (promise-type-git…) to the project; saving installs it with cfbs (needs the internet once). Hosts need python3 (test hosts have it; get_warnings advises an Install Package block for python3 before the first such block until the project has one), and git or Ansible for those blocks. - Custom Promise (custom-promise) is any other module promise type (json, yaml, groups, sshd, symlinks, docker_compose, appstreams, assert): pick promise_type, set the promiser and attributes as \`name = value\` lines (a list: \`name = { "a", "b" }\`); its module is added the same way. Its README (the module's page) lists the attributes. - Use them rather than Run Command for cloning repositories, systemd units, HTTP requests and playbooks: they report kept/repaired properly, so arrows and How often work. diff --git a/src/renderer/src/blocks/initialParams.ts b/src/renderer/src/blocks/initialParams.ts new file mode 100644 index 0000000..44c0110 --- /dev/null +++ b/src/renderer/src/blocks/initialParams.ts @@ -0,0 +1,8 @@ +import { resolveBlockShape } from './resolveBlockShape'; +import type { BlockDescriptor } from './types'; + +/** A new block's parameters: each one's default, or empty. */ +export function initialParams(descriptor: BlockDescriptor): Record { + const { parameters } = resolveBlockShape(descriptor, undefined); + return Object.fromEntries(parameters.map(parameter => [parameter.name, String(parameter.default ?? '')])); +} diff --git a/src/renderer/src/canvas/advice.test.ts b/src/renderer/src/canvas/advice.test.ts index db751a4..289f3af 100644 --- a/src/renderer/src/canvas/advice.test.ts +++ b/src/renderer/src/canvas/advice.test.ts @@ -1,6 +1,6 @@ import type { BlockInstance } from '../store/canvasSlice/types'; import type { BlockEdge } from '../store/edgesSlice/types'; -import { blockAdvice } from './advice'; +import { PYTHON_ADVICE, blockAdvice, needsPython } from './advice'; const block = (blockId: string, params: Record, extra: Partial = {}): BlockInstance => ({ instanceId: 'b', @@ -36,3 +36,19 @@ describe('block advice', () => { expect(blockAdvice(undefined, [])).toEqual([]); }); }); + +describe('python3 for promise modules', () => { + const clone = block('git-checkout', { destination: '/srv/a' }); + + it('is advised on a block whose promise type runs with Python, until something installs it', () => { + expect(blockAdvice(clone, [], [clone])).toEqual([PYTHON_ADVICE]); + const install = block('install-package', { package_name: 'curl\npython3' }, { instanceId: 'p' }); + expect(blockAdvice(clone, [], [clone, install])).toEqual([]); + }); + + it('isn’t for promise types without Python, or blocks of CFEngine’s own', () => { + expect(needsPython(block('custom-promise', { promise_type: 'docker_compose' }), [])).toBe(false); + expect(needsPython(block('custom-promise', { promise_type: 'json' }), [])).toBe(true); + expect(needsPython(block('install-package', { package_name: 'git' }), [])).toBe(false); + }); +}); diff --git a/src/renderer/src/canvas/advice.ts b/src/renderer/src/canvas/advice.ts index 5c0dd5f..7db2c32 100644 --- a/src/renderer/src/canvas/advice.ts +++ b/src/renderer/src/canvas/advice.ts @@ -1,3 +1,4 @@ +import { requiredModule } from '../blocks/requiredModule'; import type { BlockInstance } from '../store/canvasSlice/types'; import type { BlockEdge } from '../store/edgesSlice/types'; @@ -27,11 +28,27 @@ function restartAdvice(block: BlockInstance, edges: BlockEdge[]): string | null return `A ${block.params.action} with no arrow on "repaired" into it runs on every agent run (every 5 minutes). Connect it from what changes the service's config, on repaired only.`; } -export function blockAdvice(block: BlockInstance | undefined, edges: BlockEdge[]): string[] { +// Promise modules that don't run with Python. +const WITHOUT_PYTHON = new Set(['promise-type-docker-compose']); + +export const PYTHON_ADVICE = + 'Its promise type runs with python3 on the host. If your hosts may not have it, add an Install Package block for python3 before this one.'; + +/** Whether a block's promise type needs Python on the host that no Install Package block in the project brings. */ +export function needsPython(block: BlockInstance, canvas: BlockInstance[]): boolean { + const module = requiredModule(block); + if (!module || WITHOUT_PYTHON.has(module)) return false; + const installs = (other: BlockInstance) => + other.blockId === 'install-package' && (other.params.package_name ?? '').split('\n').some(name => /^python3\b/.test(name.trim())); + return !canvas.some(installs); +} + +export function blockAdvice(block: BlockInstance | undefined, edges: BlockEdge[], canvas: BlockInstance[] = []): string[] { if (!block) return []; const advice = [ block.blockId === 'create-directory' ? directoryModeAdvice(block) : null, - block.blockId === 'manage-service' ? restartAdvice(block, edges) : null + block.blockId === 'manage-service' ? restartAdvice(block, edges) : null, + needsPython(block, canvas) ? PYTHON_ADVICE : null ]; return advice.filter((item): item is string => item !== null); } diff --git a/src/renderer/src/components/PropertiesPanel.tsx b/src/renderer/src/components/PropertiesPanel.tsx index 0ac17f9..507bfc4 100644 --- a/src/renderer/src/components/PropertiesPanel.tsx +++ b/src/renderer/src/components/PropertiesPanel.tsx @@ -35,6 +35,8 @@ export type { NewClassDefinition } from './properties/NewClassModal'; interface PropertiesPanelProps { // Valid settings that are probably not meant (canvas/advice.ts). advice?: string[]; + // A fix one piece of advice offers, as a button on it. + adviceAction?: { label: string; onClick: () => void; text: string }; allInstances: BlockInstance[]; currentFileId: string | null; descriptor: BlockDescriptor | undefined; @@ -141,7 +143,18 @@ export function PropertiesPanel(props: PropertiesPanelProps) { const labelField = ( <> {props.advice?.map(text => ( - + + {props.adviceAction.label} + + ) : undefined + } + > {text} ))} diff --git a/src/renderer/src/components/modules/Modules.test.tsx b/src/renderer/src/components/modules/Modules.test.tsx index c5a7f10..3261c8f 100644 --- a/src/renderer/src/components/modules/Modules.test.tsx +++ b/src/renderer/src/components/modules/Modules.test.tsx @@ -5,6 +5,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import App from '../../App'; import { AppThemeProvider } from '../../ThemeProvider'; import { projectFilesInitialized } from '../../store/filesSlice'; +import { undone } from '../../store/history'; import { projectCreated, projectTypeChanged } from '../../store/projectSlice'; import { type TestStore, makeStore } from '../../store/test/storeTestUtils'; import { installApi, uninstallApi } from '../../test/render'; @@ -132,6 +133,20 @@ describe('the Modules view', () => { }); describe('modules from the Blocks palette', () => { + it('advises python3 for a promise-type block, and adds it with an arrow in one step', async () => { + await renderApp(); + fireEvent.click(screen.getByText(/^Git Checkout$/)); + expect(screen.getByText(/runs with python3 on the host/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add Install python3 before it' })); + const [clone, install] = store.getState().canvas; + expect(install).toMatchObject({ blockId: 'install-package', label: 'Install python3', params: { package_name: 'python3' } }); + expect(store.getState().edges).toMatchObject([{ source: install.instanceId, target: clone.instanceId, outcomes: ['kept', 'repaired'] }]); + expect(screen.queryByText(/runs with python3 on the host/)).not.toBeInTheDocument(); + store.dispatch(undone()); + expect(store.getState().canvas).toHaveLength(1); + expect(store.getState().edges).toEqual([]); + }); + it('points a search no block matches at the modules that do', async () => { await renderApp(); fireEvent.change(screen.getByPlaceholderText('Search blocks…'), { target: { value: 'clamav' } }); diff --git a/src/renderer/src/mcp/canvasTools.ts b/src/renderer/src/mcp/canvasTools.ts index 243f3d6..046b2cb 100644 --- a/src/renderer/src/mcp/canvasTools.ts +++ b/src/renderer/src/mcp/canvasTools.ts @@ -190,7 +190,7 @@ async function warnings(state: RootState) { const problem = param.type === 'cases' || !value ? undefined : valueProblem(param, value); return problem ? [`${param.name} ${problem}`] : []; }); - const advice = blockAdvice(block, state.edges); + const advice = blockAdvice(block, state.edges, state.canvas); const customText = custom[block.instanceId]?.length ? custom[block.instanceId] : undefined; return empty.length || invalid.length || notCompiled || advice.length || customText ? [ diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index d479948..cde0482 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -8,9 +8,10 @@ import { DndContext, type DragEndEvent, DragOverlay, type DragStartEvent, Pointe import type { ReactFlowInstance } from '@xyflow/react'; import { duplicateDefinitionKeys, newDefinitionEntry } from '../blocks/definitionEntries'; +import { initialParams } from '../blocks/initialParams'; import { blockDescriptorsById } from '../blocks/loadBlocks'; import { PROMISE_TYPE_ICONS } from '../blocks/promiseTypeIcons'; -import { primaryPromiseType, resolveBlockShape } from '../blocks/resolveBlockShape'; +import { primaryPromiseType } from '../blocks/resolveBlockShape'; import type { BlockDescriptor } from '../blocks/types'; import { blockAdvice } from '../canvas/advice'; import type { ChainOwner } from '../canvas/dataChains'; @@ -136,6 +137,7 @@ import { useClipboardActions } from './useClipboardActions'; import { useGroupActions } from './useGroupActions'; import { isEditableTarget, modalOpen, useKeyboardShortcuts } from './useKeyboardShortcuts'; import { useProjectModules } from './useProjectModules'; +import { usePythonAdvice } from './usePythonAdvice'; import { useSelection } from './useSelection'; import { useSideBar } from './useSideBar'; @@ -190,11 +192,6 @@ function OtherTab({ return ; } -function initialParams(descriptor: BlockDescriptor): Record { - const { parameters } = resolveBlockShape(descriptor, undefined); - return Object.fromEntries(parameters.map(parameter => [parameter.name, String(parameter.default ?? '')])); -} - // The Properties panel with nothing selected: the open file's settings. function CurrentFileSettings() { const dispatch = useAppDispatch(); @@ -357,6 +354,7 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti const [maximized, setMaximized] = useState(false); const sideBar = useSideBar({ activeTab, maximized, setActiveTab, setMaximized }); const modules = useProjectModules(sideBar.activeView === 'modules', PROJECT_TABS[activeTab]); + const pythonAdvice = usePythonAdvice(allInstances); // Another tab, or the canvas's file or a block: the module page closes. const showTab = (tab: number) => { modules.close(); @@ -993,7 +991,8 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti onArrowOutcomesChange={(edgeId, outcomes) => dispatch(edgeOutcomesChanged({ edgeId, outcomes }))} onArrowRemove={edgeId => dispatch(edgeRemoved({ edgeId }))} onLabelChange={label => selectedInstance && dispatch(blockLabelChanged({ instanceId: selectedInstance.instanceId, label }))} - advice={blockAdvice(selectedInstance, allEdges)} + advice={blockAdvice(selectedInstance, allEdges, allInstances)} + adviceAction={pythonAdvice(selectedInstance)} onRunIntervalChange={runInterval => selectedInstance && dispatch(blockRunIntervalChanged({ instanceId: selectedInstance.instanceId, runInterval }))} onNoteChange={note => selectedInstance && dispatch(blockNoteChanged({ instanceId: selectedInstance.instanceId, note }))} onParamChange={(paramName, value, entryId) => diff --git a/src/renderer/src/pages/usePythonAdvice.ts b/src/renderer/src/pages/usePythonAdvice.ts new file mode 100644 index 0000000..ccffd04 --- /dev/null +++ b/src/renderer/src/pages/usePythonAdvice.ts @@ -0,0 +1,34 @@ +import { initialParams } from '../blocks/initialParams'; +import { blockDescriptorsById } from '../blocks/loadBlocks'; +import { PYTHON_ADVICE, needsPython } from '../canvas/advice'; +import { useAppDispatch } from '../store'; +import { blockAdded } from '../store/canvasSlice'; +import type { BlockInstance } from '../store/canvasSlice/types'; +import { edgeAdded } from '../store/edgesSlice'; +import { inOneStep } from '../store/history'; + +/** + * The python3 advice's fix, as a button on it: an Install Package block for python3 above a + * promise-type block, with an arrow into it, as one undo step. + */ +export function usePythonAdvice(canvas: BlockInstance[]) { + const dispatch = useAppDispatch(); + const addBefore = (block: BlockInstance) => + inOneStep(dispatch, () => { + const install = blockDescriptorsById.get('install-package'); + if (!install) return; + const at = block.position ?? { x: 0, y: 0 }; + const added = dispatch( + blockAdded({ + blockId: install.id, + fileId: block.fileId, + label: 'Install python3', + params: { ...initialParams(install), package_name: 'python3' }, + position: { x: at.x, y: at.y - 140 } + }) + ); + dispatch(edgeAdded({ fileId: block.fileId, source: added.payload.instanceId, target: block.instanceId, outcomes: ['kept', 'repaired'] })); + }); + return (block: BlockInstance | undefined) => + block && needsPython(block, canvas) ? { text: PYTHON_ADVICE, label: 'Add Install python3 before it', onClick: () => addBefore(block) } : undefined; +} From 51777e247baa4a6fbafaafa61bfa7bdabe1d6dc6 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 19:50:02 +0300 Subject: [PATCH 22/23] The python3 advice can be ignored for the project, and its buttons sit under it Ignoring is saved with the project (dismissed_advice in project.json) and hides the advice in Properties and get_warnings; undo brings it back. The advice shows without its icon, which took the text's width. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/mcpTools/guide.ts | 2 +- src/renderer/src/canvas/advice.test.ts | 1 + src/renderer/src/canvas/advice.ts | 7 ++++-- .../src/components/DeploymentView.tsx | 1 + .../src/components/PropertiesPanel.tsx | 25 ++++++++++--------- .../src/components/TestResultsView.tsx | 1 + .../src/components/modules/Modules.test.tsx | 10 ++++++++ src/renderer/src/mcp/canvasTools.ts | 2 +- src/renderer/src/mcp/testTools.ts | 1 + src/renderer/src/pages/ProjectView.tsx | 3 ++- src/renderer/src/pages/usePythonAdvice.ts | 16 +++++++++--- src/renderer/src/project/cfbsProject.test.ts | 8 ++++++ src/renderer/src/project/cfbsProject.ts | 12 +++++++-- .../src/project/projectChanges.test.ts | 1 + src/renderer/src/project/useCompiledPolicy.ts | 3 ++- src/renderer/src/project/useProjectSession.ts | 1 + .../src/store/dismissedAdviceSlice/index.ts | 18 +++++++++++++ src/renderer/src/store/history.ts | 2 +- src/renderer/src/store/index.ts | 2 ++ src/renderer/src/store/projectSlice/types.ts | 1 + 20 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 src/renderer/src/store/dismissedAdviceSlice/index.ts diff --git a/src/main/mcpTools/guide.ts b/src/main/mcpTools/guide.ts index 6359b9d..d36567d 100644 --- a/src/main/mcpTools/guide.ts +++ b/src/main/mcpTools/guide.ts @@ -63,7 +63,7 @@ const blocks = `# Blocks, arrows and groups - Modules run before the project's own policy, in their build order; they're not blocks and have no arrows. ## Blocks from CFEngine Build modules -- Git Checkout (git-checkout), Systemd Unit (systemd-unit), HTTP Request (http-request) and Run Ansible Playbook (ansible-playbook) are custom promise types (git:, systemd:, http:, ansible:). Adding one adds its module (promise-type-git…) to the project; saving installs it with cfbs (needs the internet once). Hosts need python3 (test hosts have it; get_warnings advises an Install Package block for python3 before the first such block until the project has one), and git or Ansible for those blocks. +- Git Checkout (git-checkout), Systemd Unit (systemd-unit), HTTP Request (http-request) and Run Ansible Playbook (ansible-playbook) are custom promise types (git:, systemd:, http:, ansible:). Adding one adds its module (promise-type-git…) to the project; saving installs it with cfbs (needs the internet once). Hosts need python3 (test hosts have it; get_warnings advises an Install Package block for python3 before such blocks, until the project has one or the user ignores the advice), and git or Ansible for those blocks. - Custom Promise (custom-promise) is any other module promise type (json, yaml, groups, sshd, symlinks, docker_compose, appstreams, assert): pick promise_type, set the promiser and attributes as \`name = value\` lines (a list: \`name = { "a", "b" }\`); its module is added the same way. Its README (the module's page) lists the attributes. - Use them rather than Run Command for cloning repositories, systemd units, HTTP requests and playbooks: they report kept/repaired properly, so arrows and How often work. diff --git a/src/renderer/src/canvas/advice.test.ts b/src/renderer/src/canvas/advice.test.ts index 289f3af..0469241 100644 --- a/src/renderer/src/canvas/advice.test.ts +++ b/src/renderer/src/canvas/advice.test.ts @@ -44,6 +44,7 @@ describe('python3 for promise modules', () => { expect(blockAdvice(clone, [], [clone])).toEqual([PYTHON_ADVICE]); const install = block('install-package', { package_name: 'curl\npython3' }, { instanceId: 'p' }); expect(blockAdvice(clone, [], [clone, install])).toEqual([]); + expect(blockAdvice(clone, [], [clone], ['python3'])).toEqual([]); }); it('isn’t for promise types without Python, or blocks of CFEngine’s own', () => { diff --git a/src/renderer/src/canvas/advice.ts b/src/renderer/src/canvas/advice.ts index 7db2c32..52ff39e 100644 --- a/src/renderer/src/canvas/advice.ts +++ b/src/renderer/src/canvas/advice.ts @@ -31,6 +31,9 @@ function restartAdvice(block: BlockInstance, edges: BlockEdge[]): string | null // Promise modules that don't run with Python. const WITHOUT_PYTHON = new Set(['promise-type-docker-compose']); +// The key a project ignores this advice by (dismissedAdvice). +export const PYTHON_ADVICE_KEY = 'python3'; + export const PYTHON_ADVICE = 'Its promise type runs with python3 on the host. If your hosts may not have it, add an Install Package block for python3 before this one.'; @@ -43,12 +46,12 @@ export function needsPython(block: BlockInstance, canvas: BlockInstance[]): bool return !canvas.some(installs); } -export function blockAdvice(block: BlockInstance | undefined, edges: BlockEdge[], canvas: BlockInstance[] = []): string[] { +export function blockAdvice(block: BlockInstance | undefined, edges: BlockEdge[], canvas: BlockInstance[] = [], dismissed: string[] = []): string[] { if (!block) return []; const advice = [ block.blockId === 'create-directory' ? directoryModeAdvice(block) : null, block.blockId === 'manage-service' ? restartAdvice(block, edges) : null, - needsPython(block, canvas) ? PYTHON_ADVICE : null + !dismissed.includes(PYTHON_ADVICE_KEY) && needsPython(block, canvas) ? PYTHON_ADVICE : null ]; return advice.filter((item): item is string => item !== null); } diff --git a/src/renderer/src/components/DeploymentView.tsx b/src/renderer/src/components/DeploymentView.tsx index e58f8c3..0c091ef 100644 --- a/src/renderer/src/components/DeploymentView.tsx +++ b/src/renderer/src/components/DeploymentView.tsx @@ -42,6 +42,7 @@ const snapshot = (): ProjectData => { files: state.files, groups: state.groups, modules: state.modules, + dismissedAdvice: state.dismissedAdvice, testEnvironments: [] }; }; diff --git a/src/renderer/src/components/PropertiesPanel.tsx b/src/renderer/src/components/PropertiesPanel.tsx index 507bfc4..6989324 100644 --- a/src/renderer/src/components/PropertiesPanel.tsx +++ b/src/renderer/src/components/PropertiesPanel.tsx @@ -36,7 +36,7 @@ interface PropertiesPanelProps { // Valid settings that are probably not meant (canvas/advice.ts). advice?: string[]; // A fix one piece of advice offers, as a button on it. - adviceAction?: { label: string; onClick: () => void; text: string }; + adviceAction?: { label: string; onClick: () => void; onIgnore?: () => void; text: string }; allInstances: BlockInstance[]; currentFileId: string | null; descriptor: BlockDescriptor | undefined; @@ -143,19 +143,20 @@ export function PropertiesPanel(props: PropertiesPanelProps) { const labelField = ( <> {props.advice?.map(text => ( - + + {text} + {props.adviceAction?.text === text && ( + + - ) : undefined - } - > - {text} + {props.adviceAction.onIgnore && ( + + )} + + )} ))} onLabelChange(event.target.value)} fullWidth size="small" sx={inputSx} /> diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index 0fb400a..67ab742 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -124,6 +124,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, files: state.files, groups: state.groups, modules: state.modules, + dismissedAdvice: state.dismissedAdvice, testEnvironments: [] }, project! diff --git a/src/renderer/src/components/modules/Modules.test.tsx b/src/renderer/src/components/modules/Modules.test.tsx index 3261c8f..e447b6b 100644 --- a/src/renderer/src/components/modules/Modules.test.tsx +++ b/src/renderer/src/components/modules/Modules.test.tsx @@ -147,6 +147,16 @@ describe('modules from the Blocks palette', () => { expect(store.getState().edges).toEqual([]); }); + it('ignores the python3 advice for the whole project, until undone', async () => { + await renderApp(); + fireEvent.click(screen.getByText(/^Git Checkout$/)); + fireEvent.click(screen.getByRole('button', { name: 'Ignore in this project' })); + expect(screen.queryByText(/runs with python3 on the host/)).not.toBeInTheDocument(); + expect(store.getState().dismissedAdvice).toEqual(['python3']); + store.dispatch(undone()); + expect(await screen.findByText(/runs with python3 on the host/)).toBeInTheDocument(); + }); + it('points a search no block matches at the modules that do', async () => { await renderApp(); fireEvent.change(screen.getByPlaceholderText('Search blocks…'), { target: { value: 'clamav' } }); diff --git a/src/renderer/src/mcp/canvasTools.ts b/src/renderer/src/mcp/canvasTools.ts index 046b2cb..d2c36d4 100644 --- a/src/renderer/src/mcp/canvasTools.ts +++ b/src/renderer/src/mcp/canvasTools.ts @@ -190,7 +190,7 @@ async function warnings(state: RootState) { const problem = param.type === 'cases' || !value ? undefined : valueProblem(param, value); return problem ? [`${param.name} ${problem}`] : []; }); - const advice = blockAdvice(block, state.edges, state.canvas); + const advice = blockAdvice(block, state.edges, state.canvas, state.dismissedAdvice); const customText = custom[block.instanceId]?.length ? custom[block.instanceId] : undefined; return empty.length || invalid.length || notCompiled || advice.length || customText ? [ diff --git a/src/renderer/src/mcp/testTools.ts b/src/renderer/src/mcp/testTools.ts index 4de78ea..348fccd 100644 --- a/src/renderer/src/mcp/testTools.ts +++ b/src/renderer/src/mcp/testTools.ts @@ -95,6 +95,7 @@ const contentOf = (state: RootState, project: Project) => files: state.files, groups: state.groups, modules: state.modules, + dismissedAdvice: state.dismissedAdvice, testEnvironments: [] }, project diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index cde0482..c7cee1d 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -355,6 +355,7 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti const sideBar = useSideBar({ activeTab, maximized, setActiveTab, setMaximized }); const modules = useProjectModules(sideBar.activeView === 'modules', PROJECT_TABS[activeTab]); const pythonAdvice = usePythonAdvice(allInstances); + const dismissedAdvice = useAppSelector(state => state.dismissedAdvice); // Another tab, or the canvas's file or a block: the module page closes. const showTab = (tab: number) => { modules.close(); @@ -991,7 +992,7 @@ export default function ProjectView({ dirty, locked, onConnectAgent, onOpenSetti onArrowOutcomesChange={(edgeId, outcomes) => dispatch(edgeOutcomesChanged({ edgeId, outcomes }))} onArrowRemove={edgeId => dispatch(edgeRemoved({ edgeId }))} onLabelChange={label => selectedInstance && dispatch(blockLabelChanged({ instanceId: selectedInstance.instanceId, label }))} - advice={blockAdvice(selectedInstance, allEdges, allInstances)} + advice={blockAdvice(selectedInstance, allEdges, allInstances, dismissedAdvice)} adviceAction={pythonAdvice(selectedInstance)} onRunIntervalChange={runInterval => selectedInstance && dispatch(blockRunIntervalChanged({ instanceId: selectedInstance.instanceId, runInterval }))} onNoteChange={note => selectedInstance && dispatch(blockNoteChanged({ instanceId: selectedInstance.instanceId, note }))} diff --git a/src/renderer/src/pages/usePythonAdvice.ts b/src/renderer/src/pages/usePythonAdvice.ts index ccffd04..2fbd3a8 100644 --- a/src/renderer/src/pages/usePythonAdvice.ts +++ b/src/renderer/src/pages/usePythonAdvice.ts @@ -1,9 +1,10 @@ import { initialParams } from '../blocks/initialParams'; import { blockDescriptorsById } from '../blocks/loadBlocks'; -import { PYTHON_ADVICE, needsPython } from '../canvas/advice'; -import { useAppDispatch } from '../store'; +import { PYTHON_ADVICE, PYTHON_ADVICE_KEY, needsPython } from '../canvas/advice'; +import { useAppDispatch, useAppSelector } from '../store'; import { blockAdded } from '../store/canvasSlice'; import type { BlockInstance } from '../store/canvasSlice/types'; +import { adviceDismissed } from '../store/dismissedAdviceSlice'; import { edgeAdded } from '../store/edgesSlice'; import { inOneStep } from '../store/history'; @@ -13,6 +14,7 @@ import { inOneStep } from '../store/history'; */ export function usePythonAdvice(canvas: BlockInstance[]) { const dispatch = useAppDispatch(); + const dismissed = useAppSelector(state => state.dismissedAdvice.includes(PYTHON_ADVICE_KEY)); const addBefore = (block: BlockInstance) => inOneStep(dispatch, () => { const install = blockDescriptorsById.get('install-package'); @@ -30,5 +32,13 @@ export function usePythonAdvice(canvas: BlockInstance[]) { dispatch(edgeAdded({ fileId: block.fileId, source: added.payload.instanceId, target: block.instanceId, outcomes: ['kept', 'repaired'] })); }); return (block: BlockInstance | undefined) => - block && needsPython(block, canvas) ? { text: PYTHON_ADVICE, label: 'Add Install python3 before it', onClick: () => addBefore(block) } : undefined; + block && !dismissed && needsPython(block, canvas) + ? { + text: PYTHON_ADVICE, + label: 'Add Install python3 before it', + onClick: () => addBefore(block), + // The project's hosts have python3: no more advice about it, in Properties or for agents. + onIgnore: () => dispatch(adviceDismissed(PYTHON_ADVICE_KEY)) + } + : undefined; } diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts index b34fdd6..09d817e 100644 --- a/src/renderer/src/project/cfbsProject.test.ts +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -41,6 +41,14 @@ describe('cfbsProject', () => { expect(restored.derivedNodes).toEqual(state.derivedNodes); }); + it('keeps the advice the project ignores', () => { + const { state } = buildProject(); + const saved = toCfbsProject({ ...state, dismissedAdvice: ['python3'] }, IDENTITY); + expect(saved.project.dismissed_advice).toEqual(['python3']); + expect(fromBuilderProject(asBuilderJson(saved)).dismissedAdvice).toEqual(['python3']); + expect('dismissed_advice' in toCfbsProject({ ...state, dismissedAdvice: [] }, IDENTITY).project).toBe(false); + }); + it('mirrors the file tree: a top-level file is a file module, a top-level folder a directory module', () => { const { fileId, folderId, state } = buildProject(); const { project, modules } = toCfbsProject(state, IDENTITY); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index c942e2f..85f775a 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -31,6 +31,8 @@ type Position = { x: number; y: number }; export interface ProjectMeta { current_file_id: string | null; + // Advice the project ignores (canvas/advice.ts keys). + dismissed_advice?: string[]; // In file order; each file's `path` is where its policy is written. files: FileMeta[]; // `path` is the folder's directory, e.g. "./services/". @@ -263,7 +265,8 @@ export function toCfbsProject(data: ProjectData, identity: Identity): CfbsProjec module_name: identity.moduleName, folders: data.files.folders.map(folder => ({ ...folder, path: paths.get(folder.id)! })), files: data.files.files.map(file => toFileMeta(file, pathOf(file), data)), - current_file_id: data.files.currentFileId + current_file_id: data.files.currentFileId, + ...(data.dismissedAdvice.length ? { dismissed_advice: data.dismissedAdvice } : {}) }, modules: [...modules].map(([name, bundles]) => toModule(name, bundles)), provided: toProvided(modules, identity), @@ -362,6 +365,7 @@ function readBuilderProject(project: ProjectMeta): ProjectData { edges, files: { currentFileId, files, folders: folders.map(({ id, name, parentId }) => ({ id, name, parentId })) }, groups, + dismissedAdvice: Array.isArray(project.dismissed_advice) ? project.dismissed_advice.filter(key => typeof key === 'string') : [], // Read from cfbs.json and their own files; see loadCfbsProject. modules: [], testEnvironments: [] @@ -467,7 +471,11 @@ export function loadCfbsProject( const data = builder === null || builder === undefined ? null : fromBuilderProject(builder); if (data && data.files.files.length > 0) return { ...identity, data: { ...data, modules, testEnvironments: environments }, masterfiles }; const files = filesReducer(undefined, projectFilesInitialized(name, ownPolicyFiles(Array.isArray(json.build) ? json.build : []))); - return { ...identity, data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [], modules, testEnvironments: environments }, masterfiles }; + return { + ...identity, + data: { canvas: [], derivedNodes: {}, dismissedAdvice: [], edges: [], files, groups: [], modules, testEnvironments: environments }, + masterfiles + }; } // The input answers in a module's input.json (its questions, each with a `response`), by variable. diff --git a/src/renderer/src/project/projectChanges.test.ts b/src/renderer/src/project/projectChanges.test.ts index 4e47760..568a062 100644 --- a/src/renderer/src/project/projectChanges.test.ts +++ b/src/renderer/src/project/projectChanges.test.ts @@ -28,6 +28,7 @@ const project = ({ edges, files: { currentFileId: null, files, folders: [] }, modules: [], + dismissedAdvice: [], groups, testEnvironments: [] }); diff --git a/src/renderer/src/project/useCompiledPolicy.ts b/src/renderer/src/project/useCompiledPolicy.ts index 3fc8368..7d798d3 100644 --- a/src/renderer/src/project/useCompiledPolicy.ts +++ b/src/renderer/src/project/useCompiledPolicy.ts @@ -37,7 +37,8 @@ export function useCompiledPolicy(enabled: boolean): CompiledPolicyState { const groups = useAppSelector(state => state.groups); const meta = useMemo( - () => (project ? toCfbsProject({ canvas, derivedNodes, edges, files, groups, modules: [], testEnvironments: [] }, project).project : null), + () => + project ? toCfbsProject({ canvas, derivedNodes, edges, files, groups, modules: [], dismissedAdvice: [], testEnvironments: [] }, project).project : null, [project, canvas, derivedNodes, edges, files, groups] ); // What the compiler is sent. diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts index cd00fa2..b8340b4 100644 --- a/src/renderer/src/project/useProjectSession.ts +++ b/src/renderer/src/project/useProjectSession.ts @@ -16,6 +16,7 @@ import { moduleNameFor } from './moduleName'; const snapshotOf = (state: RootState): ProjectData => ({ canvas: state.canvas, derivedNodes: state.derivedNodes, + dismissedAdvice: state.dismissedAdvice, edges: state.edges, files: state.files, groups: state.groups, diff --git a/src/renderer/src/store/dismissedAdviceSlice/index.ts b/src/renderer/src/store/dismissedAdviceSlice/index.ts new file mode 100644 index 0000000..a958327 --- /dev/null +++ b/src/renderer/src/store/dismissedAdviceSlice/index.ts @@ -0,0 +1,18 @@ +import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; + +import { projectCreated, projectLoaded } from '../projectSlice'; + +/** Advice the project ignores (canvas/advice.ts keys, e.g. "python3"): saved with it, undoable. */ +const dismissedAdviceSlice = createSlice({ + name: 'dismissedAdvice', + initialState: [] as string[], + reducers: { + adviceDismissed: (state, action: PayloadAction) => (state.includes(action.payload) ? state : [...state, action.payload]) + }, + extraReducers: builder => { + builder.addCase(projectCreated, () => []).addCase(projectLoaded, (_state, action) => action.payload.content.dismissedAdvice ?? []); + } +}); + +export const { adviceDismissed } = dismissedAdviceSlice.actions; +export default dismissedAdviceSlice.reducer; diff --git a/src/renderer/src/store/history.ts b/src/renderer/src/store/history.ts index 9ae5c1d..415cf76 100644 --- a/src/renderer/src/store/history.ts +++ b/src/renderer/src/store/history.ts @@ -19,7 +19,7 @@ export function inOneStep(dispatch: Dispatch, run: () => void) { } const LIMIT = 100; -export const UNDOABLE_KEYS = ['canvas', 'derivedNodes', 'edges', 'files', 'groups', 'modules', 'testEnvironments'] as const; +export const UNDOABLE_KEYS = ['canvas', 'derivedNodes', 'dismissedAdvice', 'edges', 'files', 'groups', 'modules', 'testEnvironments'] as const; export type UndoableKey = (typeof UNDOABLE_KEYS)[number]; // Text edits dispatch per keystroke: consecutive ones to the same field merge diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index c89211f..565a595 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -6,6 +6,7 @@ import { requiredModule } from '../blocks/requiredModule'; import canvasReducer from './canvasSlice'; import clipboardReducer from './clipboardSlice'; import derivedNodesReducer from './derivedNodesSlice'; +import dismissedAdviceReducer from './dismissedAdviceSlice'; import edgesReducer from './edgesSlice'; import filesReducer from './filesSlice'; import groupsReducer from './groupsSlice'; @@ -18,6 +19,7 @@ const contentReducer = combineReducers({ canvas: canvasReducer, clipboard: clipboardReducer, derivedNodes: derivedNodesReducer, + dismissedAdvice: dismissedAdviceReducer, edges: edgesReducer, files: filesReducer, groups: groupsReducer, diff --git a/src/renderer/src/store/projectSlice/types.ts b/src/renderer/src/store/projectSlice/types.ts index d07de6f..fc97025 100644 --- a/src/renderer/src/store/projectSlice/types.ts +++ b/src/renderer/src/store/projectSlice/types.ts @@ -28,6 +28,7 @@ export interface Project { export interface ProjectContentState { canvas: BlockInstance[]; derivedNodes: Record; + dismissedAdvice?: string[]; edges: BlockEdge[]; files: { currentFileId: string | null; files: PolicyFile[]; folders: PolicyFolder[] }; groups: BlockGroup[]; From acec1a495c8989d7d357fab0fa36a91c12806336 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 16 Oct 2026 20:08:47 +0300 Subject: [PATCH 23/23] The demo downloads the CFEngine logo with an HTTP Request and shows it The landing page shows the logo, which an http promise (promise-type-http) fetches into the web root at most once a day, after python3 is installed. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/test-environments.spec.ts | 4 ++ src/renderer/src/demo/nginxDemoProject.ts | 53 ++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/e2e/test-environments.spec.ts b/e2e/test-environments.spec.ts index 25fe134..f988056 100644 --- a/e2e/test-environments.spec.ts +++ b/e2e/test-environments.spec.ts @@ -110,6 +110,10 @@ test.describe('with Docker', () => { const response = await fetch('http://localhost:8080/'); expect(response.status).toBe(200); expect(await response.text()).toContain('CFEngine Policy Builder demo project'); + // The logo the http promise downloaded (promise-type-http, added with its block). + const logo = await fetch('http://localhost:8080/cfengine-logo.svg'); + expect(logo.status).toBe(200); + expect(await logo.text()).toContain(' { diff --git a/src/renderer/src/demo/nginxDemoProject.ts b/src/renderer/src/demo/nginxDemoProject.ts index d9a4576..44e515e 100644 --- a/src/renderer/src/demo/nginxDemoProject.ts +++ b/src/renderer/src/demo/nginxDemoProject.ts @@ -138,10 +138,12 @@ const LANDING_PAGE_TEMPLATE = ` .badge { display: inline-block; margin-bottom: 16px; padding: 2px 10px; border-radius: 999px; background: var(--accent); color: #fff; font-size: 13px; font-weight: 600; } footer { margin-top: 32px; font-size: 13px; color: var(--muted); } + .logo { display: block; height: 40px; margin-bottom: 24px; }
+ {{#classes.common:webserver_role}}Web server role{{/classes.common:webserver_role}}

CFEngine Policy Builder demo project

This page, the nginx it runs on and everything around them were set up by policy built visually in CFEngine Policy Builder.

@@ -171,6 +173,36 @@ function buildLandingPageBlock(): DemoBlock { }; } +// The logo the landing page shows, fetched from cfengine.com once a day: an HTTP Request block, +// whose promise type comes from the promise-type-http module (adding the block adds it). +function buildLogoBlock(): DemoBlock { + return { + blockId: 'http-request', + label: 'Download the CFEngine logo', + note: 'The landing page shows it; fetched at most once a day.', + runInterval: '1440', + params: { + url: 'https://cfengine.com/images/cfengine-logo.svg', + method: 'GET', + file: '/var/www/html/cfengine-logo.svg', + headers: '', + payload: '', + insecure: 'false' + } + }; +} + +// The http promise type runs with python3 on the host. +function buildPythonBlock(): DemoBlock { + return { + blockId: 'install-package', + label: 'Install python3', + note: 'The logo download is an http promise, which runs with python3.', + params: { package_name: 'python3', version: '' }, + condition: { mode: 'if', kind: 'class', className: 'common:webserver_role' } + }; +} + // The landing page's folder, made explicitly: a file's missing parents would be created 700. function buildWebRootBlock(): DemoBlock { return { blockId: 'create-directory', label: 'Create web root', params: { path: '/var/www/html', mode: '755', owner: 'root', group: 'root' } }; @@ -279,6 +311,8 @@ const POSITIONS: Record = { 'Keep nginx running': { x: 420, y: 220 }, 'Create web root': { x: 500, y: 680 }, 'Publish the demo landing page': { x: 560, y: 440 }, + 'Install python3': { x: 1240, y: 780 }, + 'Download the CFEngine logo': { x: 900, y: 780 }, 'Remove default nginx site': { x: 580, y: 920 }, 'Lock down nginx config files': { x: 580, y: 1060 }, 'Prune old nginx logs': { x: 580, y: 1260 }, @@ -319,7 +353,7 @@ export function createNginxDemoProject(dispatch: AppDispatch): void { const webserverFileId = webserverFile.payload.id; describe( webserverFileId, - 'Turns a webserver_role host into an nginx web server. It installs nginx, removes a conflicting Apache, renders nginx.conf from a template and restarts nginx only when that file changes. It keeps the service running and publishes a landing page. It also tidies up: removes the default site, locks down config permissions, prunes old logs and creates a deploy user.' + 'Turns a webserver_role host into an nginx web server. It installs nginx, removes a conflicting Apache, renders nginx.conf from a template and restarts nginx only when that file changes. It keeps the service running and publishes a landing page, with the CFEngine logo downloaded from cfengine.com. It also tidies up: removes the default site, locks down config permissions, prunes old logs and creates a deploy user.' ); const [install, ...beforeTemplate] = buildWebserverBlocksBeforeTemplate(); const renderTemplate = buildRenderTemplateBlock(); @@ -327,7 +361,20 @@ export function createNginxDemoProject(dispatch: AppDispatch): void { const keepRunning = buildKeepRunningBlock(); const landingPage = buildLandingPageBlock(); const webRoot = buildWebRootBlock(); - const column = [install, ...beforeTemplate, renderTemplate, manageService, keepRunning, webRoot, landingPage, ...buildWebserverBlocksAfterTemplate()]; + const python = buildPythonBlock(); + const logo = buildLogoBlock(); + const column = [ + install, + ...beforeTemplate, + renderTemplate, + manageService, + keepRunning, + webRoot, + landingPage, + python, + logo, + ...buildWebserverBlocksAfterTemplate() + ]; const ids = column.map(block => dispatch(blockAdded({ ...block, fileId: webserverFileId, position: positionOf(block) })).payload.instanceId); const idOf = (block: DemoBlock) => ids[column.indexOf(block)]; const arrow = (source: DemoBlock, target: DemoBlock, outcomes: BlockOutcome[]) => @@ -337,6 +384,8 @@ export function createNginxDemoProject(dispatch: AppDispatch): void { arrow(install, landingPage, ['kept', 'repaired']); arrow(renderTemplate, manageService, ['repaired']); arrow(webRoot, landingPage, ['kept', 'repaired']); + arrow(webRoot, logo, ['kept', 'repaired']); + arrow(python, logo, ['kept', 'repaired']); const securityFileId = dispatch(fileAdded('Security')).payload.id; describe(