diff --git a/cli/commands/init/config-generator.ts b/cli/commands/init/config-generator.ts index 622909d108..0292962799 100644 --- a/cli/commands/init/config-generator.ts +++ b/cli/commands/init/config-generator.ts @@ -31,20 +31,22 @@ export interface CreatePackageJsonOptions { }>; } -export async function createPackageJson( - projectDir: string, - projectName?: string, - options: CreatePackageJsonOptions = {}, -): Promise { - const fs = createFileSystem(); - - // Read any existing package.json (e.g. from template) to merge dependencies - const templateDeps: Record = { ...(options.dependencies ?? {}) }; - const pkgPath = join(projectDir, "package.json"); - if (await fs.exists(pkgPath)) { - const existing = JSON.parse(await fs.readTextFile(pkgPath)); - Object.assign(templateDeps, existing.dependencies ?? {}); - } +/** + * Render the scaffold's `package.json`. + * + * Pure so that both the disk-writing CLI path and + * {@link ../../shared/project-creation.ts | materializeScaffold} emit the same + * bytes for the same template — the parity the CLI and Studio scaffolds are + * required to hold. + */ +export function buildPackageJson( + projectName: string, + options: CreatePackageJsonOptions & { existingDependencies?: Record } = {}, +): string { + const templateDeps: Record = { + ...(options.dependencies ?? {}), + ...(options.existingDependencies ?? {}), + }; // Merge per-integration deps. First declaration wins; collisions are logged. const integrationDeps: Record = {}; @@ -64,7 +66,6 @@ export async function createPackageJson( } } - const dirName = projectDir.split(/[/\\]/).pop(); const veryfrontVersionRange = `^${VERSION}`; const firstPartyExtensionPackages = options.firstPartyExtensions ?? []; const requiredExtensionDeps = Object.fromEntries( @@ -74,7 +75,7 @@ export async function createPackageJson( ]), ); const packageJson = { - name: projectName ?? dirName ?? "veryfront-project", + name: projectName, version: "0.1.0", type: "module", scripts: { @@ -103,9 +104,34 @@ export async function createPackageJson( }, }; + return JSON.stringify(packageJson, null, 2); +} + +/** Default project name when neither a name nor a directory name is available. */ +export const FALLBACK_PROJECT_NAME = "veryfront-project"; + +export async function createPackageJson( + projectDir: string, + projectName?: string, + options: CreatePackageJsonOptions = {}, +): Promise { + const fs = createFileSystem(); + + // Read any existing package.json (e.g. from template) to merge dependencies + const pkgPath = join(projectDir, "package.json"); + let existingDependencies: Record | undefined; + if (await fs.exists(pkgPath)) { + const existing = JSON.parse(await fs.readTextFile(pkgPath)); + existingDependencies = existing.dependencies ?? {}; + } + + const dirName = projectDir.split(/[/\\]/).pop(); await fs.writeTextFile( - join(projectDir, "package.json"), - JSON.stringify(packageJson, null, 2), + pkgPath, + buildPackageJson(projectName ?? dirName ?? FALLBACK_PROJECT_NAME, { + ...options, + existingDependencies, + }), ); logger.debug('Created package.json with "type": "module"'); diff --git a/cli/commands/init/deno-config-generator.ts b/cli/commands/init/deno-config-generator.ts index 555d6922ad..72873f356b 100644 --- a/cli/commands/init/deno-config-generator.ts +++ b/cli/commands/init/deno-config-generator.ts @@ -14,6 +14,14 @@ const DENO_CONFIG = { }, }; +/** + * Render the scaffold's `deno.json`. Pure, so the disk-writing CLI path and + * `materializeScaffold` emit the same bytes. + */ +export function buildDenoConfig(): string { + return JSON.stringify(DENO_CONFIG, null, 2) + "\n"; +} + /** * Write a thin `deno.json` to the scaffolded project directory. Relies on * exact-version `npm:` specs so task execution stays hosted by Deno without @@ -28,5 +36,5 @@ export async function createDenoConfig(projectDir: string): Promise { if (await fs.exists(target)) { throw new Error(`Refusing to overwrite existing deno.json at ${target}`); } - await fs.writeTextFile(target, JSON.stringify(DENO_CONFIG, null, 2) + "\n"); + await fs.writeTextFile(target, buildDenoConfig()); } diff --git a/cli/shared/project-creation.ts b/cli/shared/project-creation.ts index 8f42f5c689..2480b90d74 100644 --- a/cli/shared/project-creation.ts +++ b/cli/shared/project-creation.ts @@ -3,8 +3,13 @@ import { cliLogger as logger } from "#cli/utils"; import { createFileSystem } from "veryfront/platform"; import { join } from "veryfront/platform/path"; import { ensureDir } from "#std/fs.ts"; -import { createDenoConfig } from "../commands/init/deno-config-generator.ts"; -import { createPackageJson } from "../commands/init/config-generator.ts"; +import { buildDenoConfig, createDenoConfig } from "../commands/init/deno-config-generator.ts"; +import { + buildPackageJson, + createPackageJson, + type CreatePackageJsonOptions, + FALLBACK_PROJECT_NAME, +} from "../commands/init/config-generator.ts"; import type { InitRuntime, InitTemplate } from "../commands/init/types.ts"; import { type EnvPromptResult, @@ -446,6 +451,55 @@ async function installProjectDependencies( return status; } +interface ScaffoldAssembly { + /** Template, feature and integration files, in write order. */ + files: TemplateFile[]; + envVars: EnvVarConfig[]; + tips: string[]; + packageJsonOptions: CreatePackageJsonOptions; +} + +/** + * Resolve a template into the files a new project starts with. + * + * The single assembly both `createProject` (which writes them to disk) and + * {@link materializeScaffold} (which returns them) go through, so no caller + * can drift from another. + */ +async function assembleScaffold(request: { + template: InitTemplate; + features: FeatureName[]; + integrations: IntegrationName[]; +}): Promise { + const template = await loadTemplateFiles(request.template); + const envVars = [...template.envVars]; + + const featureAssembly = await assembleFeatureFiles( + request.features, + template.files, + envVars, + ); + const integrationAssembly = await assembleIntegrationFiles( + request.integrations, + featureAssembly.files, + envVars, + ); + + return { + files: integrationAssembly.files, + envVars, + tips: [...featureAssembly.tips, ...integrationAssembly.tips], + packageJsonOptions: { + dependencies: template.dependencies, + firstPartyExtensions: template.firstPartyExtensions, + integrations: integrationAssembly.loadedIntegrations.map((integration) => ({ + name: integration.config.name, + npmDependencies: integration.config.npmDependencies, + })), + }, + }; +} + export async function createProject( request: CreateProjectRequest, dependencies: CreateProjectDependencies = {}, @@ -472,34 +526,16 @@ export async function createProject( throw createConfigError(`Directory "${projectName}" already exists`); } - const template = await loadTemplateFiles(request.template); - const allEnvVars = [...template.envVars]; - - const featureAssembly = await assembleFeatureFiles( - request.features, - template.files, - allEnvVars, - ); - const integrationAssembly = await assembleIntegrationFiles( - request.integrations, - featureAssembly.files, - allEnvVars, - ); + const assembly = await assembleScaffold(request); if (projectName !== undefined) await ensureDir(projectDir); - const createdPaths = await writeScaffoldFiles(projectDir, integrationAssembly.files); - const featureTips = [...featureAssembly.tips, ...integrationAssembly.tips]; + const createdPaths = await writeScaffoldFiles(projectDir, assembly.files); + const featureTips = assembly.tips; + const allEnvVars = assembly.envVars; if (request.includePackageMetadata) { - await createPackageJson(projectDir, projectName, { - dependencies: template.dependencies, - firstPartyExtensions: template.firstPartyExtensions, - integrations: integrationAssembly.loadedIntegrations.map((integration) => ({ - name: integration.config.name, - npmDependencies: integration.config.npmDependencies, - })), - }); + await createPackageJson(projectDir, projectName, assembly.packageJsonOptions); createdPaths.push("package.json"); if (request.runtime === "deno") { @@ -541,3 +577,130 @@ export async function createProject( featureTips, }; } + +/** + * Slugs other product surfaces use for a template this CLI names differently. + * + * Studio's "blank" project is the CLI's `minimal` starter. Mapping the two + * vocabularies here is what lets a hosted caller materialize the same bytes + * `veryfront init` writes instead of copying a separately maintained project. + */ +export const SCAFFOLD_TEMPLATE_ALIASES: Readonly> = Object.freeze({ + blank: "minimal", + "pages-router": "ai-agent", + "app-router": "ai-agent", +}); + +/** Canonical starter template for a slug, or `null` when nothing matches. */ +export function resolveScaffoldTemplate(slug: string): InitTemplate | null { + const canonical = SCAFFOLD_TEMPLATE_ALIASES[slug] ?? slug; + return (STARTER_TEMPLATE_NAMES as readonly string[]).includes(canonical) + ? canonical as InitTemplate + : null; +} + +/** Every template slug a caller may ask for, canonical names and aliases. */ +export function listScaffoldTemplates(): string[] { + return [...STARTER_TEMPLATE_NAMES, ...Object.keys(SCAFFOLD_TEMPLATE_ALIASES)].sort(); +} + +/** What to build: which starter, under what name, for which runtime. */ +export interface MaterializeScaffoldRequest { + /** Canonical template name or a slug from {@link SCAFFOLD_TEMPLATE_ALIASES}. */ + template: string; + /** + * Written into `package.json#name`. Validated exactly as `veryfront init` + * validates it, so neither path can produce a project the other rejects. + */ + projectName?: string; + runtime?: InitRuntime; + features?: FeatureName[]; + integrations?: IntegrationName[]; + environmentValues?: Record; + /** Include `package.json` (and `deno.json` on the Deno runtime). */ + includePackageMetadata?: boolean; +} + +/** A new project: every file it starts with, plus anything worth telling the author. */ +export interface MaterializedScaffold { + /** Canonical template the requested slug resolved to. */ + template: InitTemplate; + /** Complete project contents, sorted by path. */ + files: TemplateFile[]; + tips: string[]; +} + +/** + * Produce the complete contents of a new project without touching a disk. + * + * This is the artifact a hosted "create project" flow should write, so a + * project created outside the CLI is byte-identical to one `veryfront init` + * scaffolds from the same template. It runs the same assembly and the same + * `package.json` / `deno.json` / `.gitignore` / `.env` generators that + * {@link createProject} writes, so the two cannot report different files for + * the same request. + */ +export async function materializeScaffold( + request: MaterializeScaffoldRequest, +): Promise { + const template = resolveScaffoldTemplate(request.template); + if (!template) { + throw TEMPLATE_NOT_FOUND.create({ + detail: `Unknown template "${request.template}". Available templates: ${ + listScaffoldTemplates().join(", ") + }`, + }); + } + + if (request.projectName !== undefined) { + const nameError = validateProjectName(request.projectName); + if (nameError) throw createConfigError(nameError); + } + + const features = request.features ?? []; + const integrations = request.integrations ?? []; + validateOrThrow("features", features, validateFeatures); + validateOrThrow("integrations", integrations, validateIntegrations); + + const assembly = await assembleScaffold({ template, features, integrations }); + + // Keyed by path, because the generated files below are the same files the + // CLI writes last: whatever a template ships at those paths is merged in, + // never emitted twice. + const files = new Map(assembly.files.map((file) => [file.path, file.content])); + files.delete(".env"); + files.delete(".env.example"); + + if (request.includePackageMetadata !== false) { + const shipped = files.get("package.json"); + files.set( + "package.json", + buildPackageJson(request.projectName ?? FALLBACK_PROJECT_NAME, { + ...assembly.packageJsonOptions, + existingDependencies: shipped ? JSON.parse(shipped).dependencies ?? {} : undefined, + }), + ); + if (request.runtime === "deno") { + files.set("deno.json", buildDenoConfig()); + } + } + + if (assembly.envVars.length) { + const env = await promptForEnvVars(dedupeEnvVars(assembly.envVars), { + skipPrompt: true, + prefilledValues: request.environmentValues ?? {}, + }); + files.set(".env", env.envContent); + files.set(".env.example", env.envExampleContent); + } + + files.set(".gitignore", generateGitignoreContent(files.get(".gitignore"))); + + return { + template, + files: [...files] + .map(([path, content]) => ({ path, content })) + .sort((a, b) => a.path.localeCompare(b.path)), + tips: assembly.tips, + }; +} diff --git a/deno.json b/deno.json index 269f7ad086..003e4d2438 100644 --- a/deno.json +++ b/deno.json @@ -174,6 +174,7 @@ "./testing/assert": "./src/testing/assert.ts", "./testing/bdd": "./src/testing/bdd.ts", "./cli": "./cli/main.ts", + "./scaffold": "./templates/scaffold.ts", "./chat/message-prep": "./src/chat/message-prep.ts", "./server": "./src/server/index.ts" }, diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index a3b928f681..8a30e69854 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -7,47 +7,48 @@ order: 1 ## Contents -| Import | Description | -| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`veryfront`](./veryfront/index.md) | Core app config and routing. | -| [`veryfront/agent`](./veryfront/agent.md) | Agents, AG-UI handlers, and memory. | -| [`veryfront/chat`](./veryfront/chat.md) | Chat components and hooks. | -| [`veryfront/cli`](./veryfront/cli.md) | CLI runtime helpers. | -| [`veryfront/context`](./veryfront/context.md) | Page context. | -| [`veryfront/embedding`](./veryfront/embedding.md) | Embedding and retrieval helpers. | -| [`veryfront/errors`](./veryfront/errors.md) | Structured error system with slug-based registry, RFC 9457 HTTP problem details, error boundaries for HTTP and CLI, and user-friendly formatting. | -| [`veryfront/eval`](./veryfront/eval.md) | First-class eval primitives for agent quality checks. | -| [`veryfront/extensions`](./veryfront/extensions.md) | Extension contracts and loader helpers. | -| [`veryfront/fonts`](./veryfront/fonts.md) | Font components. | -| [`veryfront/fs`](./veryfront/fs.md) | Filesystem and path utilities. | -| [`veryfront/head`](./veryfront/head.md) | Document metadata components. | -| [`veryfront/index.client`](./veryfront/index.client.md) | Client and SSR-safe root helpers. | -| [`veryfront/integrations`](./veryfront/integrations.md) | Connector metadata and remote tools. | -| [`veryfront/knowledge`](./veryfront/knowledge.md) | Project knowledge retrieval helpers. | -| [`veryfront/markdown`](./veryfront/markdown.md) | Markdown rendering. | -| [`veryfront/mcp`](./veryfront/mcp.md) | MCP server helpers. | -| [`veryfront/mdx`](./veryfront/mdx.md) | MDX component overrides. | -| [`veryfront/metrics`](./veryfront/metrics.md) | Runtime/application metric hooks for project code. | -| [`veryfront/middleware`](./veryfront/middleware.md) | HTTP middleware. | -| [`veryfront/oauth`](./veryfront/oauth.md) | OAuth provider helpers. | -| [`veryfront/observability`](./veryfront/observability.md) | Tracing, metrics, errors, and logs. | -| [`veryfront/prompt`](./veryfront/prompt.md) | MCP prompt definitions. | -| [`veryfront/provider`](./veryfront/provider.md) | Model provider registry. | -| [`veryfront/release-assets`](./veryfront/release-assets.md) | Content-addressed release asset build, schema, cache, and consumption contracts. | -| [`veryfront/resource`](./veryfront/resource.md) | MCP resource definitions. | -| [`veryfront/router`](./veryfront/router.md) | Client navigation and route context. | -| [`veryfront/runs`](./veryfront/runs.md) | Canonical durable task and workflow runs. | -| [`veryfront/sandbox`](./veryfront/sandbox.md) | Isolated execution. | -| [`veryfront/schedule`](./veryfront/schedule.md) | Source-defined recurring schedules for Veryfront projects. | -| [`veryfront/schemas`](./veryfront/schemas.md) | Validation schemas. | -| [`veryfront/security`](./veryfront/security.md) | Security layer - input validation with size limits, CORS configuration, CSP and security headers, path traversal prevention, and secure filesystem access. | -| [`veryfront/server`](./veryfront/server.md) | Server runtime helpers. | -| [`veryfront/skill`](./veryfront/skill.md) | Agent skills. Public API for the agent skills system. Skills are project-level capabilities defined as SKILL.md files following the agentskills.io specification. | -| [`veryfront/task`](./veryfront/task.md) | Source-defined tasks for Veryfront projects. | -| [`veryfront/testing`](./veryfront/testing.md) | Test utilities. | -| [`veryfront/tool`](./veryfront/tool.md) | Tool definitions and execution. | -| [`veryfront/trigger`](./veryfront/trigger.md) | Shared source-trigger discovery and local execution primitives. | -| [`veryfront/ui`](./veryfront/ui.md) | UI primitives - the base layer for veryfront/chat components. | -| [`veryfront/utils`](./veryfront/utils.md) | Runtime utilities. | -| [`veryfront/webhook`](./veryfront/webhook.md) | Source-defined webhooks for Veryfront projects. | -| [`veryfront/workflow`](./veryfront/workflow.md) | Workflows. | +| Import | Description | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`veryfront`](./veryfront/index.md) | Core app config and routing. | +| [`veryfront/agent`](./veryfront/agent.md) | Agents, AG-UI handlers, and memory. | +| [`veryfront/chat`](./veryfront/chat.md) | Chat components and hooks. | +| [`veryfront/cli`](./veryfront/cli.md) | CLI runtime helpers. | +| [`veryfront/context`](./veryfront/context.md) | Page context. | +| [`veryfront/embedding`](./veryfront/embedding.md) | Embedding and retrieval helpers. | +| [`veryfront/errors`](./veryfront/errors.md) | Structured error system with slug-based registry, RFC 9457 HTTP problem details, error boundaries for HTTP and CLI, and user-friendly formatting. | +| [`veryfront/eval`](./veryfront/eval.md) | First-class eval primitives for agent quality checks. | +| [`veryfront/extensions`](./veryfront/extensions.md) | Extension contracts and loader helpers. | +| [`veryfront/fonts`](./veryfront/fonts.md) | Font components. | +| [`veryfront/fs`](./veryfront/fs.md) | Filesystem and path utilities. | +| [`veryfront/head`](./veryfront/head.md) | Document metadata components. | +| [`veryfront/index.client`](./veryfront/index.client.md) | Client and SSR-safe root helpers. | +| [`veryfront/integrations`](./veryfront/integrations.md) | Connector metadata and remote tools. | +| [`veryfront/knowledge`](./veryfront/knowledge.md) | Project knowledge retrieval helpers. | +| [`veryfront/markdown`](./veryfront/markdown.md) | Markdown rendering. | +| [`veryfront/mcp`](./veryfront/mcp.md) | MCP server helpers. | +| [`veryfront/mdx`](./veryfront/mdx.md) | MDX component overrides. | +| [`veryfront/metrics`](./veryfront/metrics.md) | Runtime/application metric hooks for project code. | +| [`veryfront/middleware`](./veryfront/middleware.md) | HTTP middleware. | +| [`veryfront/oauth`](./veryfront/oauth.md) | OAuth provider helpers. | +| [`veryfront/observability`](./veryfront/observability.md) | Tracing, metrics, errors, and logs. | +| [`veryfront/prompt`](./veryfront/prompt.md) | MCP prompt definitions. | +| [`veryfront/provider`](./veryfront/provider.md) | Model provider registry. | +| [`veryfront/release-assets`](./veryfront/release-assets.md) | Content-addressed release asset build, schema, cache, and consumption contracts. | +| [`veryfront/resource`](./veryfront/resource.md) | MCP resource definitions. | +| [`veryfront/router`](./veryfront/router.md) | Client navigation and route context. | +| [`veryfront/runs`](./veryfront/runs.md) | Canonical durable task and workflow runs. | +| [`veryfront/sandbox`](./veryfront/sandbox.md) | Isolated execution. | +| [`veryfront/scaffold`](./veryfront/scaffold.md) | Create a Veryfront project from a starter template. `materializeScaffold()` returns the complete contents of a new project - every file `veryfront init` writes, including `package.json`, `AGENTS.md` and `.gitignore` - without touching a disk. A service that creates projects on a user's behalf can write them wherever it stores project files and get a project identical to one scaffolded on the command line. Templates are addressed by name (`minimal`, `ai-agent`, `docs-agent`, `agentic-workflow`, `multi-agent-system`, `coding-agent`, `saas-starter`). `listScaffoldTemplates()` enumerates every accepted name and `resolveScaffoldTemplate()` reports which starter a name selects. | +| [`veryfront/schedule`](./veryfront/schedule.md) | Source-defined recurring schedules for Veryfront projects. | +| [`veryfront/schemas`](./veryfront/schemas.md) | Validation schemas. | +| [`veryfront/security`](./veryfront/security.md) | Security layer - input validation with size limits, CORS configuration, CSP and security headers, path traversal prevention, and secure filesystem access. | +| [`veryfront/server`](./veryfront/server.md) | Server runtime helpers. | +| [`veryfront/skill`](./veryfront/skill.md) | Agent skills. Public API for the agent skills system. Skills are project-level capabilities defined as SKILL.md files following the agentskills.io specification. | +| [`veryfront/task`](./veryfront/task.md) | Source-defined tasks for Veryfront projects. | +| [`veryfront/testing`](./veryfront/testing.md) | Test utilities. | +| [`veryfront/tool`](./veryfront/tool.md) | Tool definitions and execution. | +| [`veryfront/trigger`](./veryfront/trigger.md) | Shared source-trigger discovery and local execution primitives. | +| [`veryfront/ui`](./veryfront/ui.md) | UI primitives - the base layer for veryfront/chat components. | +| [`veryfront/utils`](./veryfront/utils.md) | Runtime utilities. | +| [`veryfront/webhook`](./veryfront/webhook.md) | Source-defined webhooks for Veryfront projects. | +| [`veryfront/workflow`](./veryfront/workflow.md) | Workflows. | diff --git a/docs/api-reference/veryfront/scaffold.md b/docs/api-reference/veryfront/scaffold.md new file mode 100644 index 0000000000..e0c955843d --- /dev/null +++ b/docs/api-reference/veryfront/scaffold.md @@ -0,0 +1,57 @@ +--- +title: "veryfront/scaffold" +description: "Create a Veryfront project from a starter template. `materializeScaffold()` returns the complete contents of a new project - every file `veryfront init` writes, including `package.json`, `AGENTS.md` and `.gitignore` - without touching a disk. A service that creates projects on a user's behalf can write them wherever it stores project files and get a project identical to one scaffolded on the command line. Templates are addressed by name (`minimal`, `ai-agent`, `docs-agent`, `agentic-workflow`, `multi-agent-system`, `coding-agent`, `saas-starter`). `listScaffoldTemplates()` enumerates every accepted name and `resolveScaffoldTemplate()` reports which starter a name selects." +order: 30 +--- + +## Import + +```ts +import { + listScaffoldTemplates, + materializeScaffold, + resolveScaffoldTemplate, + SCAFFOLD_TEMPLATE_ALIASES, +} from "veryfront/scaffold"; +``` + +## Examples + +### Create a project and store its files + +```ts +import { materializeScaffold } from "veryfront/scaffold"; + +const { files } = await materializeScaffold({ + template: "minimal", + projectName: "my-app", +}); + +for (const file of files) { + console.log(file.path, file.content.length); +} +``` + +## Exports + +### Components + +| Name | Description | Source | +| --------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `SCAFFOLD_TEMPLATE_ALIASES` | Slugs other product surfaces use for a template this CLI names differently. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L588) | + +### Functions + +| Name | Description | Source | +| ------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `listScaffoldTemplates` | Every template slug a caller may ask for, canonical names and aliases. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L603) | +| `materializeScaffold` | Produce the complete contents of a new project without touching a disk. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L643) | +| `resolveScaffoldTemplate` | Canonical starter template for a slug, or `null` when nothing matches. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L595) | + +### Types + +| Name | Description | Source | +| ---------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `MaterializedScaffold` | A new project: every file it starts with, plus anything worth telling the author. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L625) | +| `MaterializeScaffoldRequest` | What to build: which starter, under what name, for which runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L608) | +| `TemplateFile` | | [source](https://github.com/veryfront/veryfront-code/blob/main/templates/types.ts#L17) | diff --git a/docs/api-reference/veryfront/schedule.md b/docs/api-reference/veryfront/schedule.md index c4e01d6a01..8595e2a042 100644 --- a/docs/api-reference/veryfront/schedule.md +++ b/docs/api-reference/veryfront/schedule.md @@ -1,7 +1,7 @@ --- title: "veryfront/schedule" description: "Source-defined recurring schedules for Veryfront projects." -order: 30 +order: 31 --- ## Import diff --git a/docs/api-reference/veryfront/schemas.md b/docs/api-reference/veryfront/schemas.md index 8e617426fa..d7fb9ef5b2 100644 --- a/docs/api-reference/veryfront/schemas.md +++ b/docs/api-reference/veryfront/schemas.md @@ -1,7 +1,7 @@ --- title: "veryfront/schemas" description: "Reusable validation schemas and the `defineSchema` helper. Schema materialization requires a registered `SchemaValidator`. Veryfront runtime bootstrap registers the built-in validator before handlers run. `lazySchema` keeps module-scope schema constants import-safe before bootstrap." -order: 31 +order: 32 --- ## Import diff --git a/docs/api-reference/veryfront/security.md b/docs/api-reference/veryfront/security.md index b7aa492aa5..de50182800 100644 --- a/docs/api-reference/veryfront/security.md +++ b/docs/api-reference/veryfront/security.md @@ -1,7 +1,7 @@ --- title: "veryfront/security" description: "Security layer - input validation with size limits, CORS configuration, CSP and security headers, path traversal prevention, and secure filesystem access." -order: 32 +order: 33 --- ## Import diff --git a/docs/api-reference/veryfront/server.md b/docs/api-reference/veryfront/server.md index 21a0c48edf..4b55b48b7c 100644 --- a/docs/api-reference/veryfront/server.md +++ b/docs/api-reference/veryfront/server.md @@ -1,7 +1,7 @@ --- title: "veryfront/server" description: "Create and run Veryfront servers." -order: 33 +order: 34 --- ## Import diff --git a/docs/api-reference/veryfront/skill.md b/docs/api-reference/veryfront/skill.md index 93230dbeb0..8a651573cd 100644 --- a/docs/api-reference/veryfront/skill.md +++ b/docs/api-reference/veryfront/skill.md @@ -1,7 +1,7 @@ --- title: "veryfront/skill" description: "Agent skills. Public API for the agent skills system. Skills are project-level capabilities defined as SKILL.md files following the agentskills.io specification." -order: 34 +order: 35 --- ## Import diff --git a/docs/api-reference/veryfront/task.md b/docs/api-reference/veryfront/task.md index a1af18501e..36165e6b6a 100644 --- a/docs/api-reference/veryfront/task.md +++ b/docs/api-reference/veryfront/task.md @@ -1,7 +1,7 @@ --- title: "veryfront/task" description: "Source-defined tasks for Veryfront projects." -order: 35 +order: 36 --- ## Import diff --git a/docs/api-reference/veryfront/testing.md b/docs/api-reference/veryfront/testing.md index 936d6dcc6e..fb799309f2 100644 --- a/docs/api-reference/veryfront/testing.md +++ b/docs/api-reference/veryfront/testing.md @@ -1,7 +1,7 @@ --- title: "veryfront/testing" description: "Cross-runtime BDD assertions and test helpers." -order: 36 +order: 37 --- ## Import diff --git a/docs/api-reference/veryfront/tool.md b/docs/api-reference/veryfront/tool.md index 91c9b81985..c644f32971 100644 --- a/docs/api-reference/veryfront/tool.md +++ b/docs/api-reference/veryfront/tool.md @@ -1,7 +1,7 @@ --- title: "veryfront/tool" description: "Define tools with schema-backed inputs for agents and MCP." -order: 37 +order: 38 --- ## Import diff --git a/docs/api-reference/veryfront/trigger.md b/docs/api-reference/veryfront/trigger.md index fb0a2cac2a..6d7b24071e 100644 --- a/docs/api-reference/veryfront/trigger.md +++ b/docs/api-reference/veryfront/trigger.md @@ -1,7 +1,7 @@ --- title: "veryfront/trigger" description: "Shared source-trigger discovery and local execution primitives." -order: 38 +order: 39 --- ## Import diff --git a/docs/api-reference/veryfront/ui.md b/docs/api-reference/veryfront/ui.md index ac92b21979..536e729aa6 100644 --- a/docs/api-reference/veryfront/ui.md +++ b/docs/api-reference/veryfront/ui.md @@ -1,7 +1,7 @@ --- title: "veryfront/ui" description: "`veryfront/ui`: the public UI primitive library. Dependency-light forks of Veryfront Studio's design system (cva/Slot inlined; colours remapped to veryfront's `[var(--token)]` vocabulary; zero external packages). These are the base layer the `veryfront/chat` components are built on: `chat` depends on `ui`, never the reverse." -order: 39 +order: 40 --- ## Import diff --git a/docs/api-reference/veryfront/utils.md b/docs/api-reference/veryfront/utils.md index 3c4fec10e1..d083a68b97 100644 --- a/docs/api-reference/veryfront/utils.md +++ b/docs/api-reference/veryfront/utils.md @@ -1,7 +1,7 @@ --- title: "veryfront/utils" description: "Runtime detection, logging, constants, hashing, and feature flags." -order: 40 +order: 41 --- ## Import diff --git a/docs/api-reference/veryfront/webhook.md b/docs/api-reference/veryfront/webhook.md index c5b48b8fa8..dbc84122fa 100644 --- a/docs/api-reference/veryfront/webhook.md +++ b/docs/api-reference/veryfront/webhook.md @@ -1,7 +1,7 @@ --- title: "veryfront/webhook" description: "Source-defined webhooks for Veryfront projects." -order: 41 +order: 42 --- ## Import diff --git a/docs/api-reference/veryfront/workflow.md b/docs/api-reference/veryfront/workflow.md index 286d99a740..72d3f2ba29 100644 --- a/docs/api-reference/veryfront/workflow.md +++ b/docs/api-reference/veryfront/workflow.md @@ -1,7 +1,7 @@ --- title: "veryfront/workflow" description: "DAG-based agentic workflows with human-in-the-loop support." -order: 42 +order: 43 --- ## Import diff --git a/docs/guides/multi-agent.md b/docs/guides/multi-agent.md index 696e3f41c0..fd659a0bf7 100644 --- a/docs/guides/multi-agent.md +++ b/docs/guides/multi-agent.md @@ -6,10 +6,10 @@ order: 28 Veryfront supports two agent composition patterns: -- Wrap agents as tools with `agentAsTool` or `getAgentsAsTools`. +- Let one agent call others by naming them in `delegates`. - Run agents as ordered workflow steps. -Use agent-as-tool when the parent should choose the order at runtime. Use a workflow when the order is known in advance. +Use delegation when the parent should choose the order at runtime. Use a workflow when the order is known in advance. Each agent can omit `model` and use `openai/gpt-5.4-nano`, set `"auto"` for runtime selection, or set an explicit `provider/model` override when you need one. @@ -51,21 +51,20 @@ export default agent({ ```ts // agents/orchestrator.ts -import { agent, getAgentsAsTools } from "veryfront/agent"; +import { agent } from "veryfront/agent"; export default agent({ id: "orchestrator", system: "You coordinate research and writing. Use the researcher to gather facts, then the writer to produce the article.", - tools: getAgentsAsTools({ - researcher: "Research a topic using web search", - writer: "Write an article from research notes", - }), + delegates: ["researcher", "writer"], maxSteps: 10, }); ``` -`getAgentsAsTools()` wraps each agent as a tool. The orchestrator decides when to call each agent based on its system prompt. Each sub-agent runs its own tool loop independently. +Each id in `delegates` becomes an `agent_` tool. The orchestrator decides when to call each agent based on its system prompt, and each sub-agent runs its own tool loop independently. + +Name the delegates rather than building the tools yourself. Discovery loads `agents/` in filename order, so a top-level `getAgentsAsTools()` in `orchestrator.ts` runs before `researcher.ts` and `writer.ts` have registered and returns nothing. `delegates` resolves each agent when the run starts, so load order cannot matter. Reach for `agentAsTool()` or `getAgentsAsTools()` only where you register the agents yourself and control the order. ### Invoke the orchestrator diff --git a/scripts/docs/generate-api-reference.test.ts b/scripts/docs/generate-api-reference.test.ts index ebf77bfc1e..f4de04dff8 100644 --- a/scripts/docs/generate-api-reference.test.ts +++ b/scripts/docs/generate-api-reference.test.ts @@ -1,4 +1,8 @@ -import { assertEquals, assertMatch, assertStringIncludes } from "#std/assert"; +import { + assertEquals, + assertMatch, + assertStringIncludes, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#std/testing/bdd"; import { compile } from "npm:@mdx-js/mdx@3.1.1"; diff --git a/scripts/test/npm-install-smoke.sh b/scripts/test/npm-install-smoke.sh index f788097377..c2136792a5 100755 --- a/scripts/test/npm-install-smoke.sh +++ b/scripts/test/npm-install-smoke.sh @@ -10,7 +10,10 @@ # 4. installing @veryfront/ext-auth-jwt makes the extension load # 5. a broken transitive dependency surfaces the real error, not a # misleading "extension not installed" skip -# 6. the packed ai-agent starter starts under Node and renders over HTTP +# 6. `veryfront/scaffold` resolves by its published subpath and materializes +# a project, so a hosted "create project" flow never has to walk into the +# package's build output to reach the starter templates +# 7. the packed ai-agent starter starts under Node and renders over HTTP # without unresolved generated runtime helpers # # Requires: `deno task build:npm` output in ./npm, node + npm + curl on PATH. @@ -167,7 +170,36 @@ echo "$BROKEN_OUTPUT" | grep -q "jose" || echo "$BROKEN_OUTPUT" | grep -q "install @veryfront/ext-auth-jwt alongside veryfront" && fail "broken transitive dependency was misclassified as a missing extension: $BROKEN_OUTPUT" -echo "== 6. packed ai-agent starter: dev server renders over HTTP" +echo "== 6. scaffold resolves through the published exports map" +# Deliberately a bare specifier, resolved by Node against the package's own +# `exports`. The deep `./node_modules/veryfront/esm/...` paths used above +# bypass that map, so only this proves the subpath is actually exported — +# the failure a hosted create-project flow would hit, and the one this +# repository's in-tree tests cannot see because they all import by relative +# path. Without the entry it fails with ERR_PACKAGE_PATH_NOT_EXPORTED. +node --input-type=module -e " +const { materializeScaffold, listScaffoldTemplates } = await import('veryfront/scaffold'); +const names = listScaffoldTemplates(); +for (const name of ['minimal', 'ai-agent', 'agentic-workflow']) { + if (!names.includes(name)) throw new Error('scaffold cannot create ' + name); +} +const { files } = await materializeScaffold({ + template: 'minimal', + projectName: 'smoke-app', +}); +const paths = files.map((file) => file.path); +for (const required of ['package.json', 'AGENTS.md', '.gitignore']) { + if (!paths.includes(required)) { + throw new Error('materialized project is missing ' + required); + } +} +const pkg = JSON.parse(files.find((file) => file.path === 'package.json').content); +if (pkg.name !== 'smoke-app') { + throw new Error('materialized package.json has the wrong name: ' + pkg.name); +} +" || fail "veryfront/scaffold did not resolve from an installed package" + +echo "== 7. packed ai-agent starter: dev server renders over HTTP" cp -R "$ROOT_DIR/templates/files/ai-agent/." "$WORKDIR/" DEV_PORT="${VF_NPM_SSR_SMOKE_PORT:-43119}" diff --git a/templates/files/agentic-workflow/app/api/workflows/sample-runs.ts b/templates/files/agentic-workflow/app/api/workflows/sample-runs.ts index 6c0eb0b9e5..d421cfa495 100644 --- a/templates/files/agentic-workflow/app/api/workflows/sample-runs.ts +++ b/templates/files/agentic-workflow/app/api/workflows/sample-runs.ts @@ -1,7 +1,10 @@ -export interface DemoWorkflowStep { - id: string; - name: string; - status: "pending" | "running" | "completed" | "waiting_for_approval" | "failed"; +/** Node statuses the framework reports; the UI keys its icons off these. */ +export type DemoNodeStatus = "pending" | "running" | "completed" | "failed" | "skipped"; + +export interface DemoNodeState { + nodeId: string; + status: DemoNodeStatus; + attempt: number; output?: string | Record; } @@ -12,9 +15,8 @@ export interface DemoWorkflowRun { input: { topic: string }; createdAt: string; currentNodes: string[]; - nodeStates: Record; + nodeStates: Record; pendingApprovals: Array<{ id: string; status: "pending" | "approved" | "rejected" }>; - steps: DemoWorkflowStep[]; } const globalStore = globalThis as typeof globalThis & { @@ -38,37 +40,31 @@ export function createDemoWorkflowRun( createdAt: new Date().toISOString(), currentNodes: [], nodeStates: { - research: { status: "completed" }, - "write-article": { status: "completed" }, - "editorial-review": { status: "completed" }, - publish: { status: "completed" }, - }, - pendingApprovals: [], - steps: [ - { - id: "research", - name: "Research", + research: { + nodeId: "research", status: "completed", + attempt: 1, output: "Found key points and source material.", }, - { - id: "write-article", - name: "Write article", + "write-article": { + nodeId: "write-article", status: "completed", + attempt: 1, output: "Drafted a concise article from the research notes.", }, - { - id: "editorial-review", - name: "Editorial review", + "editorial-review": { + nodeId: "editorial-review", status: "completed", + attempt: 1, }, - { - id: "publish", - name: "Publish", + publish: { + nodeId: "publish", status: "completed", + attempt: 1, output: { published: true }, }, - ], + }, + pendingApprovals: [], }; } diff --git a/templates/files/agentic-workflow/app/page.tsx b/templates/files/agentic-workflow/app/page.tsx index a3b37f8ac5..73e72bafbf 100644 --- a/templates/files/agentic-workflow/app/page.tsx +++ b/templates/files/agentic-workflow/app/page.tsx @@ -11,6 +11,13 @@ const STATUS_STYLES: Record = { pending: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400', } +/** A run's input is workflow-defined, so narrow it before reading `topic`. */ +function topicOf(input: unknown): string { + return typeof input === 'object' && input !== null && 'topic' in input + ? String((input as { topic: unknown }).topic) + : '' +} + export default function WorkflowDashboard(): React.JSX.Element { const [topic, setTopic] = useState('') const { start, isStarting } = useWorkflowStart({ workflowId: 'content-pipeline' }) @@ -72,7 +79,7 @@ export default function WorkflowDashboard(): React.JSX.Element { >
-

{wf.input?.topic || 'Untitled'}

+

{topicOf(wf.input) || 'Untitled'}

{new Date(wf.createdAt).toLocaleString()}

diff --git a/templates/files/agentic-workflow/app/workflows/[id]/page.tsx b/templates/files/agentic-workflow/app/workflows/[id]/page.tsx index 6707f3dadf..48ee88786a 100644 --- a/templates/files/agentic-workflow/app/workflows/[id]/page.tsx +++ b/templates/files/agentic-workflow/app/workflows/[id]/page.tsx @@ -8,10 +8,17 @@ const STEP_ICONS: Record = { completed: '\u2713', running: '\u25C9', pending: '\u25CB', - waiting_for_approval: '\u23F8', + skipped: '\u23F8', failed: '\u2717', } +/** A run's input is workflow-defined, so narrow it before reading `topic`. */ +function topicOf(input: unknown): string { + return typeof input === 'object' && input !== null && 'topic' in input + ? String((input as { topic: unknown }).topic) + : '' +} + export default function WorkflowDetail(): React.JSX.Element { const { params } = usePageContext() const { run, pendingApprovals, isLoading, refresh } = useWorkflow({ runId: params.id }) @@ -52,21 +59,21 @@ export default function WorkflowDetail(): React.JSX.Element {
← Back -

{run.input?.topic || 'Workflow'}

+

{topicOf(run.input) || 'Workflow'}

Started {new Date(run.createdAt).toLocaleString()}

- {/* Steps */} + {/* One card per workflow node, in definition order */}
- {run.steps?.map((step: any) => ( -
- {STEP_ICONS[step.status] || '\u25CB'} + {Object.entries(run.nodeStates).map(([nodeId, node]) => ( +
+ {STEP_ICONS[node.status] || '\u25CB'}
-

{step.name}

- {step.output && ( -

{typeof step.output === 'string' ? step.output : JSON.stringify(step.output)}

+

{nodeId}

+ {node.output !== undefined && ( +

{typeof node.output === 'string' ? node.output : JSON.stringify(node.output)}

)}
- {step.status} + {node.status}
))}
@@ -78,6 +85,7 @@ export default function WorkflowDetail(): React.JSX.Element {

Review the draft before publishing.

\n
\n \n\n {/* Workflow runs */}\n
\n

Recent Runs

\n\n {isLoading ? (\n

Loading...

\n ) : runs.length === 0 ? (\n
\n

No workflows yet. Start one above.

\n
\n ) : (\n
\n {runs.map((wf) => (\n \n
\n
\n

{wf.input?.topic || 'Untitled'}

\n

{new Date(wf.createdAt).toLocaleString()}

\n
\n \n {wf.status.replace(/_/g, ' ')}\n \n
\n \n ))}\n
\n )}\n
\n
\n
\n )\n}\n", - "app/workflows/[id]/page.tsx": "'use client'\n\nimport { useState } from 'react'\nimport { usePageContext } from 'veryfront/context'\nimport { useWorkflow } from 'veryfront/workflow'\n\nconst STEP_ICONS: Record = {\n completed: '\\u2713',\n running: '\\u25C9',\n pending: '\\u25CB',\n waiting_for_approval: '\\u23F8',\n failed: '\\u2717',\n}\n\nexport default function WorkflowDetail(): React.JSX.Element {\n const { params } = usePageContext()\n const { run, pendingApprovals, isLoading, refresh } = useWorkflow({ runId: params.id })\n const [isSubmitting, setIsSubmitting] = useState(false)\n\n async function handleApproval(approvalId: string, approved: boolean) {\n setIsSubmitting(true)\n try {\n await fetch(`/api/workflows/runs/${params.id}/approvals/${approvalId}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ approved, approver: 'user' }),\n })\n await refresh()\n } finally {\n setIsSubmitting(false)\n }\n }\n\n if (isLoading) {\n return (\n
\n

Loading workflow...

\n
\n )\n }\n\n if (!run) {\n return (\n
\n

Workflow not found

\n
\n )\n }\n\n return (\n
\n
\n ← Back\n\n

{run.input?.topic || 'Workflow'}

\n

Started {new Date(run.createdAt).toLocaleString()}

\n\n {/* Steps */}\n
\n {run.steps?.map((step: any) => (\n
\n {STEP_ICONS[step.status] || '\\u25CB'}\n
\n

{step.name}

\n {step.output && (\n

{typeof step.output === 'string' ? step.output : JSON.stringify(step.output)}

\n )}\n
\n {step.status}\n
\n ))}\n
\n\n {/* Approval */}\n {pendingApprovals.length > 0 && (\n
\n

Approval Required

\n

Review the draft before publishing.

\n
\n handleApproval(pendingApprovals[0].id, true)}\n disabled={isSubmitting}\n className=\"px-4 py-2 bg-emerald-500 text-white font-medium rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors text-sm\"\n >\n Approve\n \n handleApproval(pendingApprovals[0].id, false)}\n disabled={isSubmitting}\n className=\"px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 disabled:opacity-50 transition-colors text-sm\"\n >\n Reject\n \n
\n
\n )}\n
\n
\n )\n}\n", + "app/page.tsx": "'use client'\n\nimport { useState } from 'react'\nimport { useWorkflowStart, useWorkflowList } from 'veryfront/workflow'\n\nconst STATUS_STYLES: Record = {\n running: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',\n completed: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',\n waiting_for_approval: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',\n failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',\n pending: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400',\n}\n\n/** A run's input is workflow-defined, so narrow it before reading `topic`. */\nfunction topicOf(input: unknown): string {\n return typeof input === 'object' && input !== null && 'topic' in input\n ? String((input as { topic: unknown }).topic)\n : ''\n}\n\nexport default function WorkflowDashboard(): React.JSX.Element {\n const [topic, setTopic] = useState('')\n const { start, isStarting } = useWorkflowStart({ workflowId: 'content-pipeline' })\n const { runs, isLoading, refresh } = useWorkflowList()\n\n async function handleStart(e: React.FormEvent) {\n e.preventDefault()\n if (!topic.trim()) return\n await start({ topic: topic.trim() })\n setTopic('')\n await refresh()\n }\n\n return (\n
\n
\n
\n

Content Pipeline

\n

Research → Write → Review → Publish

\n
\n\n {/* Start new workflow */}\n
\n
\n setTopic(e.target.value)}\n placeholder=\"Enter a topic to research and write about...\"\n className=\"flex-1 px-4 py-2.5 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500\"\n />\n \n {isStarting ? 'Starting...' : 'Start'}\n \n
\n
\n\n {/* Workflow runs */}\n
\n

Recent Runs

\n\n {isLoading ? (\n

Loading...

\n ) : runs.length === 0 ? (\n
\n

No workflows yet. Start one above.

\n
\n ) : (\n
\n {runs.map((wf) => (\n \n
\n
\n

{topicOf(wf.input) || 'Untitled'}

\n

{new Date(wf.createdAt).toLocaleString()}

\n
\n \n {wf.status.replace(/_/g, ' ')}\n \n
\n \n ))}\n
\n )}\n
\n
\n
\n )\n}\n", + "app/workflows/[id]/page.tsx": "'use client'\n\nimport { useState } from 'react'\nimport { usePageContext } from 'veryfront/context'\nimport { useWorkflow } from 'veryfront/workflow'\n\nconst STEP_ICONS: Record = {\n completed: '\\u2713',\n running: '\\u25C9',\n pending: '\\u25CB',\n skipped: '\\u23F8',\n failed: '\\u2717',\n}\n\n/** A run's input is workflow-defined, so narrow it before reading `topic`. */\nfunction topicOf(input: unknown): string {\n return typeof input === 'object' && input !== null && 'topic' in input\n ? String((input as { topic: unknown }).topic)\n : ''\n}\n\nexport default function WorkflowDetail(): React.JSX.Element {\n const { params } = usePageContext()\n const { run, pendingApprovals, isLoading, refresh } = useWorkflow({ runId: params.id })\n const [isSubmitting, setIsSubmitting] = useState(false)\n\n async function handleApproval(approvalId: string, approved: boolean) {\n setIsSubmitting(true)\n try {\n await fetch(`/api/workflows/runs/${params.id}/approvals/${approvalId}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ approved, approver: 'user' }),\n })\n await refresh()\n } finally {\n setIsSubmitting(false)\n }\n }\n\n if (isLoading) {\n return (\n
\n

Loading workflow...

\n
\n )\n }\n\n if (!run) {\n return (\n
\n

Workflow not found

\n
\n )\n }\n\n return (\n
\n
\n ← Back\n\n

{topicOf(run.input) || 'Workflow'}

\n

Started {new Date(run.createdAt).toLocaleString()}

\n\n {/* One card per workflow node, in definition order */}\n
\n {Object.entries(run.nodeStates).map(([nodeId, node]) => (\n
\n {STEP_ICONS[node.status] || '\\u25CB'}\n
\n

{nodeId}

\n {node.output !== undefined && (\n

{typeof node.output === 'string' ? node.output : JSON.stringify(node.output)}

\n )}\n
\n {node.status}\n
\n ))}\n
\n\n {/* Approval */}\n {pendingApprovals.length > 0 && (\n
\n

Approval Required

\n

Review the draft before publishing.

\n
\n handleApproval(pendingApprovals[0].id, true)}\n disabled={isSubmitting}\n className=\"px-4 py-2 bg-emerald-500 text-white font-medium rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors text-sm\"\n >\n Approve\n \n handleApproval(pendingApprovals[0].id, false)}\n disabled={isSubmitting}\n className=\"px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 disabled:opacity-50 transition-colors text-sm\"\n >\n Reject\n \n
\n
\n )}\n
\n
\n )\n}\n", "globals.css": "@import \"tailwindcss\";\n", "globals.d.ts": "declare module \"*.css\";\n", "public/favicon.svg": "\n \n \n\n", "README.md": "# Agentic Workflow\n\nOrchestrated multi-step processes with human approval gates.\n\n## What's included\n\n- Content pipeline workflow (research, write, review, publish)\n- Parallel step execution\n- Human-in-the-loop approval gates\n- Dashboard to start, monitor, and approve workflow runs\n\n## Structure\n\n```\nagents/\n researcher.ts Research agent\n writer.ts Writing agent\nworkflows/content-pipeline.ts Workflow definition\napp/\n api/workflows/ Demo workflow API routes\n page.tsx Workflow dashboard\n workflows/[id]/page.tsx Run detail and approval UI\n```\n\nThis starter is not production-ready.\n", + "tools/publish.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\n\n/**\n * Final step of `workflows/content-pipeline.ts`.\n *\n * Replace the body with a call to your CMS, static site build, or storage\n * bucket. The workflow reaches this step only after the approval gate passes.\n */\nexport default tool({\n id: \"publish\",\n description: \"Publish an approved draft\",\n inputSchema: defineSchema((v) =>\n v.object({\n title: v.string().default(\"Untitled\").describe(\"Headline of the article\"),\n })\n )(),\n execute: ({ title }) => ({\n published: true,\n title,\n url: `/articles/${Date.now()}`,\n }),\n});\n", "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"strict\": true,\n \"jsx\": \"react-jsx\",\n \"skipLibCheck\": true,\n \"esModuleInterop\": true,\n \"noEmit\": true,\n \"allowImportingTsExtensions\": true,\n \"paths\": {\n \"@/*\": [\"./*\"]\n }\n },\n \"include\": [\"**/*.ts\", \"**/*.tsx\"],\n \"exclude\": [\"node_modules\"]\n}\n", - "workflows/content-pipeline.ts": "import { workflow, step, parallel, waitForApproval } from \"veryfront/workflow\";\n\nexport default workflow({\n id: \"content-pipeline\",\n description: \"Research, write, review, and publish content\",\n steps: ({ input }) => [\n step(\"research\", {\n agent: \"researcher\",\n input: { topic: input.topic },\n }),\n\n parallel(\"draft\", [\n step(\"write-article\", { agent: \"writer\" }),\n step(\"write-summary\", { agent: \"writer\", input: { format: \"summary\" } }),\n ]),\n\n waitForApproval(\"editorial-review\", {\n message: \"Review the draft before publishing\",\n timeout: \"24h\",\n }),\n\n step(\"publish\", {\n execute: async ({ previous }) => {\n // Replace with your publishing logic\n return { published: true, url: `/articles/${Date.now()}` };\n },\n }),\n ],\n});\n" + "workflows/content-pipeline.ts": "import { workflow, step, parallel, waitForApproval } from \"veryfront/workflow\";\n\n/** Typing the input makes `input.topic` available to every step below. */\ninterface ContentPipelineInput {\n topic: string;\n}\n\nexport default workflow({\n id: \"content-pipeline\",\n description: \"Research, write, review, and publish content\",\n steps: ({ input }) => [\n step(\"research\", {\n agent: \"researcher\",\n input: { topic: input.topic },\n }),\n\n parallel(\"draft\", [\n step(\"write-article\", { agent: \"writer\" }),\n step(\"write-summary\", { agent: \"writer\", input: { format: \"summary\" } }),\n ]),\n\n waitForApproval(\"editorial-review\", {\n message: \"Review the draft before publishing\",\n timeout: \"24h\",\n }),\n\n // Every step runs an agent or a tool. `tools/publish.ts` is where the\n // publishing logic lives.\n step(\"publish\", { tool: \"publish\" }),\n ],\n});\n" } }, "ai-agent": { @@ -49,7 +50,7 @@ "public/favicon.svg": "\n \n \n\n", "README.md": "# Coding Agent\n\nAn AI assistant that can read, understand, and modify project files.\n\n## What's included\n\n- Coder agent with file system tools\n- Read, list, and edit files through conversation\n- Safe search/replace editing pattern\n\n## Structure\n\n```\nagents/coder.ts Agent with coding instructions\ntools/\n read-file.ts Read file contents\n list-files.ts List directory contents\n edit-file.ts Search and replace in files\napp/\n api/ag-ui/route.ts AG-UI endpoint\n page.tsx Chat interface\n```\n\nThis starter is not production-ready.\n", "tools/edit-file.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { cwd, readTextFile, realPath, resolve, writeTextFile } from \"veryfront/fs\";\n\n/** True when `target` is the same as, or nested under, `root` (both canonical). */\nfunction isWithin(root: string, target: string): boolean {\n const r = root.replace(/\\\\/g, \"/\");\n const t = target.replace(/\\\\/g, \"/\");\n return t === r || t.startsWith(`${r}/`);\n}\n\nexport default tool({\n id: \"edit-file\",\n description: \"Edit a file by replacing a specific string with new content\",\n inputSchema: defineSchema((v) => v.object({\n path: v.string().describe(\"File path relative to the project root\"),\n search: v.string().describe(\"Exact string to find in the file\"),\n replace: v.string().describe(\"String to replace it with\"),\n }))(),\n execute: async ({ path, search, replace }) => {\n let projectDir: string;\n let absolute: string;\n try {\n // Canonicalize both sides so a symlink that points outside the project\n // is resolved to its real target before the containment check.\n projectDir = await realPath(cwd());\n absolute = await realPath(resolve(projectDir, path));\n } catch {\n return { error: `File not found: ${path}` };\n }\n if (!isWithin(projectDir, absolute)) {\n return { error: `Path escapes project directory: ${path}` };\n }\n\n const content = await readTextFile(absolute);\n if (!content.includes(search)) {\n return { error: \"Search string not found in file\" };\n }\n\n const updated = content.replace(search, replace);\n await writeTextFile(absolute, updated);\n return { path, success: true };\n },\n});\n", - "tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { cwd, readDir, realPath, resolve } from \"veryfront/fs\";\n\n/** True when `target` is the same as, or nested under, `root` (both canonical). */\nfunction isWithin(root: string, target: string): boolean {\n const r = root.replace(/\\\\/g, \"/\");\n const t = target.replace(/\\\\/g, \"/\");\n return t === r || t.startsWith(`${r}/`);\n}\n\nexport default tool({\n id: \"list-files\",\n description: \"List files in a project directory\",\n inputSchema: defineSchema((v) => v.object({\n directory: v\n .string()\n .default(\".\")\n .describe(\"Directory path relative to project root\"),\n extensions: v\n .array(v.string())\n .optional()\n .describe(\"Filter by file extensions (e.g. ['.ts', '.tsx'])\"),\n }))(),\n execute: async ({ directory, extensions }) => {\n let projectDir: string;\n let absolute: string;\n try {\n // Canonicalize both sides so a symlink that points outside the project\n // is resolved to its real target before the containment check.\n projectDir = await realPath(cwd());\n absolute = await realPath(resolve(projectDir, directory));\n } catch {\n return { error: `Directory not found: ${directory}` };\n }\n if (!isWithin(projectDir, absolute)) {\n return { error: `Path escapes project directory: ${directory}` };\n }\n\n const entries = await readDir(absolute);\n\n let files = entries\n .filter((e) => e.isFile)\n .map((e) => e.name);\n\n if (extensions?.length) {\n files = files.filter((f) =>\n extensions.some((ext) => f.endsWith(ext))\n );\n }\n\n return { directory, files, count: files.length };\n },\n});\n", + "tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { cwd, readDir, realPath, resolve } from \"veryfront/fs\";\n\n/** True when `target` is the same as, or nested under, `root` (both canonical). */\nfunction isWithin(root: string, target: string): boolean {\n const r = root.replace(/\\\\/g, \"/\");\n const t = target.replace(/\\\\/g, \"/\");\n return t === r || t.startsWith(`${r}/`);\n}\n\nexport default tool({\n id: \"list-files\",\n description: \"List files in a project directory\",\n inputSchema: defineSchema((v) => v.object({\n directory: v\n .string()\n .default(\".\")\n .describe(\"Directory path relative to project root\"),\n extensions: v\n .array(v.string())\n .optional()\n .describe(\"Filter by file extensions (e.g. ['.ts', '.tsx'])\"),\n }))(),\n execute: async ({ directory, extensions }) => {\n let projectDir: string;\n let absolute: string;\n try {\n // Canonicalize both sides so a symlink that points outside the project\n // is resolved to its real target before the containment check.\n projectDir = await realPath(cwd());\n absolute = await realPath(resolve(projectDir, directory));\n } catch {\n return { error: `Directory not found: ${directory}` };\n }\n if (!isWithin(projectDir, absolute)) {\n return { error: `Path escapes project directory: ${directory}` };\n }\n\n // `readDir` streams entries, so collect the files before filtering.\n let files: string[] = [];\n for await (const entry of readDir(absolute)) {\n if (entry.isFile) files.push(entry.name);\n }\n\n if (extensions?.length) {\n files = files.filter((file) => extensions.some((ext) => file.endsWith(ext)));\n }\n\n return { directory, files, count: files.length };\n },\n});\n", "tools/read-file.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { cwd, readTextFile, realPath, resolve } from \"veryfront/fs\";\n\n/** True when `target` is the same as, or nested under, `root` (both canonical). */\nfunction isWithin(root: string, target: string): boolean {\n const r = root.replace(/\\\\/g, \"/\");\n const t = target.replace(/\\\\/g, \"/\");\n return t === r || t.startsWith(`${r}/`);\n}\n\nexport default tool({\n id: \"read-file\",\n description: \"Read the contents of a file in the project\",\n inputSchema: defineSchema((v) => v.object({\n path: v.string().describe(\"File path relative to the project root\"),\n }))(),\n execute: async ({ path }) => {\n let projectDir: string;\n let absolute: string;\n try {\n // Canonicalize both sides so a symlink that points outside the project\n // is resolved to its real target before the containment check.\n projectDir = await realPath(cwd());\n absolute = await realPath(resolve(projectDir, path));\n } catch {\n return { error: `File not found: ${path}` };\n }\n if (!isWithin(projectDir, absolute)) {\n return { error: `Path escapes project directory: ${path}` };\n }\n const content = await readTextFile(absolute);\n return { path, content };\n },\n});\n", "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"strict\": true,\n \"jsx\": \"react-jsx\",\n \"skipLibCheck\": true,\n \"esModuleInterop\": true,\n \"noEmit\": true,\n \"allowImportingTsExtensions\": true,\n \"paths\": {\n \"@/*\": [\n \"./*\"\n ],\n \"react-markdown@*\": [\n \"./node_modules/react-markdown\"\n ],\n \"remark-gfm@*\": [\n \"./node_modules/remark-gfm\"\n ]\n }\n },\n \"include\": [\n \"**/*.ts\",\n \"**/*.tsx\"\n ],\n \"exclude\": [\n \"node_modules\"\n ]\n}\n" } @@ -86,7 +87,7 @@ }, "multi-agent-system": { "files": { - "agents/orchestrator.ts": "import { agent, getAgentsAsTools } from \"veryfront/agent\";\n\nexport default agent({\n id: \"orchestrator\",\n name: \"Agent Team\",\n description: \"Coordinate research and writing agents.\",\n system:\n \"You coordinate a team of AI agents. \" +\n \"Delegate research tasks to the researcher and writing tasks to the writer. \" +\n \"Combine their outputs into a polished response.\",\n tools: getAgentsAsTools([\"researcher\", \"writer\"]),\n maxSteps: 10,\n suggestions: [\n {\n type: \"prompt\",\n title: \"Research a topic\",\n prompt: \"Research this topic and summarize the key findings: \",\n },\n {\n type: \"prompt\",\n title: \"Write a brief\",\n prompt: \"Research and write a concise brief about \",\n },\n ],\n});\n", + "agents/orchestrator.ts": "import { agent } from \"veryfront/agent\";\n\nexport default agent({\n id: \"orchestrator\",\n name: \"Agent Team\",\n description: \"Coordinate research and writing agents.\",\n system:\n \"You coordinate a team of AI agents. \" +\n \"Delegate research tasks to the researcher and writing tasks to the writer. \" +\n \"Combine their outputs into a polished response.\",\n // Each id becomes an `agent_` tool. The specialists are looked up when\n // the run happens, so it does not matter which agent file loads first.\n delegates: [\"researcher\", \"writer\"],\n maxSteps: 10,\n suggestions: [\n {\n type: \"prompt\",\n title: \"Research a topic\",\n prompt: \"Research this topic and summarize the key findings: \",\n },\n {\n type: \"prompt\",\n title: \"Write a brief\",\n prompt: \"Research and write a concise brief about \",\n },\n ],\n});\n", "agents/researcher.ts": "import { agent } from \"veryfront/agent\";\n\nexport default agent({\n id: \"researcher\",\n system:\n \"You are a research specialist. \" +\n \"Gather comprehensive information on the given topic. \" +\n \"Present findings as structured bullet points with key facts and data.\",\n tools: true,\n maxSteps: 5,\n});\n", "agents/writer.ts": "import { agent } from \"veryfront/agent\";\n\nexport default agent({\n id: \"writer\",\n system:\n \"You are a writing specialist. \" +\n \"Take research notes and transform them into clear, engaging prose. \" +\n \"Use a professional but approachable tone.\",\n maxSteps: 3,\n});\n", "app/api/ag-ui/route.ts": "import { createAgUiHandler } from \"veryfront/agent\";\n\nexport const POST = createAgUiHandler(\"orchestrator\");\n", @@ -96,8 +97,8 @@ "globals.css": "@import \"tailwindcss\";\n", "globals.d.ts": "declare module \"*.css\";\n", "public/favicon.svg": "\n \n \n\n", - "README.md": "# Multi-Agent System\n\nA team of specialized agents that collaborate on tasks.\n\n## What's included\n\n- Orchestrator that delegates to researcher and writer agents\n- Agent-as-tool composition via `getAgentsAsTools()`\n- Web search tool (placeholder, configure your own API)\n\n## Structure\n\n```\nagents/\n orchestrator.ts Coordinates the team\n researcher.ts Gathers information\n writer.ts Produces polished content\ntools/web-search.ts Placeholder search tool\napp/\n api/ag-ui/route.ts AG-UI endpoint\n page.tsx Chat interface\n```\n\nThis starter is not production-ready.\n", - "tools/web-search.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\n\nexport default tool({\n id: \"web-search\",\n description: \"Search the web for information on a topic\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query\"),\n }))(),\n execute: async ({ query: _query }) => {\n // Connect a real search API to use this tool.\n // Popular options: Tavily, SerpAPI, Brave Search\n throw new Error(\n \"No search API configured. \" +\n \"See https://veryfront.com/docs/code/guides/tools for setup instructions.\",\n );\n },\n});\n", + "README.md": "# Multi-Agent System\n\nA team of specialized agents that collaborate on tasks.\n\n## What's included\n\n- Orchestrator that delegates to researcher and writer agents\n- Delegation via `delegates: [\"researcher\", \"writer\"]`, which the runtime\n resolves into `agent_researcher` and `agent_writer` tools when a run starts\n- Web search tool (placeholder, configure your own API)\n\n## Structure\n\n```\nagents/\n orchestrator.ts Coordinates the team\n researcher.ts Gathers information\n writer.ts Produces polished content\ntools/web-search.ts Placeholder search tool\napp/\n api/ag-ui/route.ts AG-UI endpoint\n page.tsx Chat interface\n```\n\nThis starter is not production-ready.\n", + "tools/web-search.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\n\nexport default tool({\n id: \"web-search\",\n description: \"Search the web for information on a topic\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query\"),\n }))(),\n execute: ({ query: _query }) => {\n // Connect a real search API to use this tool.\n // Popular options: Tavily, SerpAPI, Brave Search\n throw new Error(\n \"No search API configured. \" +\n \"See https://veryfront.com/docs/code/guides/tools for setup instructions.\",\n );\n },\n});\n", "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"strict\": true,\n \"jsx\": \"react-jsx\",\n \"skipLibCheck\": true,\n \"esModuleInterop\": true,\n \"noEmit\": true,\n \"allowImportingTsExtensions\": true,\n \"paths\": {\n \"@/*\": [\n \"./*\"\n ],\n \"react-markdown@*\": [\n \"./node_modules/react-markdown\"\n ],\n \"remark-gfm@*\": [\n \"./node_modules/remark-gfm\"\n ]\n }\n },\n \"include\": [\n \"**/*.ts\",\n \"**/*.tsx\"\n ],\n \"exclude\": [\n \"node_modules\"\n ]\n}\n" } }, @@ -105,7 +106,7 @@ "files": { "agents/assistant.ts": "import { agent } from \"veryfront/agent\";\n\nexport default agent({\n id: \"assistant\",\n name: \"SaaS Assistant\",\n description: \"Answer product and customer questions.\",\n system: \"You are a helpful AI assistant. Be concise and direct.\",\n tools: true,\n memory: { type: \"conversation\", maxMessages: 50 },\n maxSteps: 10,\n suggestions: [\n {\n type: \"prompt\",\n title: \"Summarize account\",\n prompt: \"Summarize the latest account activity.\",\n },\n {\n type: \"prompt\",\n title: \"Find customers\",\n prompt: \"Find customers who need attention.\",\n },\n ],\n});\n", "app/api/ag-ui/route.ts": "import { createAgUiHandler } from \"veryfront/agent\";\n\nexport const POST = createAgUiHandler(\"assistant\");\n", - "app/dashboard/page.tsx": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Chat } from \"veryfront/chat\";\nimport { MarkdownRendererProvider } from \"veryfront/markdown\";\nimport { MarkdownRenderer } from \"../markdown-renderer.tsx\";\n\ninterface Conversation {\n id: string;\n title: string;\n updatedAt: string;\n}\n\nconst INITIAL_CONVERSATIONS: Conversation[] = [\n { id: \"1\", title: \"Getting started\", updatedAt: \"Just now\" },\n];\n\nexport default function Dashboard(): React.JSX.Element {\n const [conversations] = useState(INITIAL_CONVERSATIONS);\n const [activeId, setActiveId] = useState(\"1\");\n\n return (\n
\n {/* Sidebar */}\n \n\n {/* Chat */}\n
\n \n \n \n
\n
\n );\n}\n", + "app/dashboard/page.tsx": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Chat } from \"veryfront/chat\";\nimport { MarkdownRendererProvider } from \"veryfront/markdown\";\nimport { MarkdownRenderer } from \"../markdown-renderer.tsx\";\n\ninterface Conversation {\n id: string;\n title: string;\n updatedAt: string;\n}\n\nconst INITIAL_CONVERSATIONS: Conversation[] = [\n { id: \"1\", title: \"Getting started\", updatedAt: \"Just now\" },\n];\n\nexport default function Dashboard(): React.JSX.Element {\n const [conversations] = useState(INITIAL_CONVERSATIONS);\n const [activeId, setActiveId] = useState(\"1\");\n\n return (\n
\n {/* Sidebar */}\n \n\n {/* Chat */}\n
\n \n \n \n
\n
\n );\n}\n", "app/layout.tsx": "import \"../globals.css\";\nimport { Head } from \"veryfront/head\";\n\nexport default function RootLayout({\n children,\n}: {\n children: React.ReactNode;\n}): React.ReactNode {\n return (\n <>\n \n AI SaaS\n \n \n
\n {children}\n
\n \n );\n}\n", "app/login/page.tsx": "\"use client\";\n\n// Demo sign-in: the buttons below pass straight through to /dashboard so the\n// starter is usable out of the box. To wire up real OAuth, scaffold provider\n// routes at app/api/auth/google/route.ts and app/api/auth/github/route.ts and\n// point the hrefs there. See https://veryfront.com/docs/code/guides/oauth.\nexport default function LoginPage(): React.JSX.Element {\n return (\n
\n
\n
\n

\n Welcome back\n

\n

\n Sign in to continue\n

\n
\n\n
\n \n \n \n \n \n \n \n Continue with Google\n \n \n \n \n \n Continue with GitHub\n \n
\n\n

\n \n ← Back to home\n \n

\n
\n
\n );\n}\n", "app/markdown-renderer.tsx": "\"use client\";\n\nimport ReactMarkdown from \"react-markdown@9.0.3\";\nimport remarkGfm from \"remark-gfm@4.0.1\";\nimport type { MarkdownRendererProps } from \"veryfront/markdown\";\n\n/**\n * Rich Markdown for assistant answers.\n *\n * `veryfront/markdown` presents plain source until a renderer is installed, so\n * this component supplies one. Swap in any renderer that accepts\n * `MarkdownRendererProps` to change how answers are parsed and rendered.\n */\nexport function MarkdownRenderer({ source }: MarkdownRendererProps): React.JSX.Element {\n return {source};\n}\n", @@ -114,7 +115,7 @@ "globals.d.ts": "declare module \"*.css\";\n", "public/favicon.svg": "\n \n \n\n", "README.md": "# SaaS Starter\n\nA SaaS-shaped starter with authentication, conversation memory, and a full UI.\n\n## What's included\n\n- Landing page with feature highlights\n- OAuth login (Google and GitHub)\n- Dashboard with conversation sidebar\n- Per-user conversation memory persisted across sessions\n\n## Structure\n\n```\nagents/assistant.ts Agent with conversation memory\ntools/search.ts Placeholder domain search\napp/\n api/ag-ui/route.ts AG-UI endpoint\n page.tsx Landing page\n login/page.tsx OAuth login\n dashboard/page.tsx Chat with sidebar\n```\n\nThis starter is not production-ready.\n", - "tools/search.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\n\nexport default tool({\n id: \"search\",\n description: \"Search your knowledge base\",\n inputSchema: defineSchema((v) =>\n v.object({\n query: v.string().describe(\"Search query\"),\n })\n )(),\n execute: async ({ query }) => {\n // Replace with your domain-specific search logic\n return {\n results: [],\n query,\n message: \"Connect your data source for real results.\",\n };\n },\n});\n", + "tools/search.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\n\nexport default tool({\n id: \"search\",\n description: \"Search your knowledge base\",\n inputSchema: defineSchema((v) =>\n v.object({\n query: v.string().describe(\"Search query\"),\n })\n )(),\n execute: ({ query }) => {\n // Replace with your domain-specific search logic\n return {\n results: [],\n query,\n message: \"Connect your data source for real results.\",\n };\n },\n});\n", "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"strict\": true,\n \"jsx\": \"react-jsx\",\n \"skipLibCheck\": true,\n \"esModuleInterop\": true,\n \"noEmit\": true,\n \"allowImportingTsExtensions\": true,\n \"paths\": {\n \"@/*\": [\n \"./*\"\n ],\n \"react-markdown@*\": [\n \"./node_modules/react-markdown\"\n ],\n \"remark-gfm@*\": [\n \"./node_modules/remark-gfm\"\n ]\n }\n },\n \"include\": [\n \"**/*.ts\",\n \"**/*.tsx\"\n ],\n \"exclude\": [\n \"node_modules\"\n ]\n}\n" } }, diff --git a/templates/multi-agent-delegation.test.ts b/templates/multi-agent-delegation.test.ts new file mode 100644 index 0000000000..f9e48d8f0e --- /dev/null +++ b/templates/multi-agent-delegation.test.ts @@ -0,0 +1,105 @@ +/** + * Runtime wiring gate for the `multi-agent-system` starter. + * + * The template's whole subject is delegation, and it shipped with a + * coordinator that had no one to coordinate: the orchestrator built its + * delegate tools from a top-level `getAgentsAsTools()`, but `agent()` + * registers on call and discovery loads `agents/orchestrator.ts` before + * `researcher.ts` and `writer.ts` — so that call ran against an empty + * registry and produced nothing. + * + * Neither of the gates beside this one could see it. `deno check` and + * `deno lint` in `scaffold-quality.test.ts` grade the scaffold's syntax and + * types, and both were happy with a coordinator wired to nothing; the + * delegation tests under `src/agent` use synthetic agents, so they never + * touch this template. + * + * These tests load the template's own agent modules — orchestrator FIRST, + * the order that used to break it — and assert the delegate tools exist and + * resolve to the specialists the template ships. Execution is stubbed: the + * point is the wiring, and resolving a delegate is the step that was broken. + * + * @module templates/multi-agent-delegation.test + */ + +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { buildAgentDelegateTools, getAgent } from "#veryfront/agent/index.ts"; +import type { Agent } from "#veryfront/agent/types.ts"; + +const TEMPLATE_AGENTS = "./files/multi-agent-system/agents"; + +/** The specialists the orchestrator names in `delegates`. */ +const DELEGATE_IDS = ["researcher", "writer"] as const; + +/** + * Load the template exactly the way project discovery does — coordinator + * first. Load order is the failure this file exists to catch, so it is fixed + * here rather than left to whatever order the imports happen to run in. + */ +async function loadTemplateAgents(): Promise { + const orchestrator = (await import(`${TEMPLATE_AGENTS}/orchestrator.ts`)).default as Agent; + await import(`${TEMPLATE_AGENTS}/researcher.ts`); + await import(`${TEMPLATE_AGENTS}/writer.ts`); + return orchestrator; +} + +function delegateToolNames(agent: Agent): string[] { + const tools = agent.config?.tools; + if (!tools || typeof tools !== "object") return []; + return Object.keys(tools).filter((name) => name.startsWith("agent_")).sort(); +} + +describe("multi-agent-system template delegation", () => { + it("gives the orchestrator a delegate tool per specialist, loaded coordinator-first", async () => { + const orchestrator = await loadTemplateAgents(); + + assertEquals( + delegateToolNames(orchestrator), + ["agent_researcher", "agent_writer"], + "the coordinator must ship with tools for the agents it coordinates", + ); + }); + + it("resolves each delegate to the agent the template registers", async () => { + await loadTemplateAgents(); + + for (const id of DELEGATE_IDS) { + const resolved = getAgent(id); + assert(resolved !== undefined, `delegate "${id}" is not registered`); + assertEquals(resolved.id, id); + } + }); + + /** + * The delegate tool resolves its target when it runs, not when it is built. + * Executing it with a stub proves the lookup reaches the registered agent + * instead of the "not available" branch it took when the registry was empty + * at build time. + */ + it("hands the registered specialist to the executor when a delegate runs", async () => { + await loadTemplateAgents(); + + const delegatedTo: string[] = []; + const tools = buildAgentDelegateTools({ + delegates: [...DELEGATE_IDS], + selfId: "orchestrator", + executeDelegate: ({ delegateId, agent }) => { + delegatedTo.push(`${delegateId}:${agent.id}`); + return Promise.resolve({ text: "stubbed", toolCalls: 0 }); + }, + }); + + for (const id of DELEGATE_IDS) { + const tool = tools[`agent_${id}`]; + assert(tool !== undefined, `no agent_${id} tool was built`); + await tool.execute?.({ + description: "test", + prompt: "test", + context: {}, + }, undefined); + } + + assertEquals(delegatedTo, ["researcher:researcher", "writer:writer"]); + }); +}); diff --git a/templates/scaffold-export.test.ts b/templates/scaffold-export.test.ts new file mode 100644 index 0000000000..e84216915f --- /dev/null +++ b/templates/scaffold-export.test.ts @@ -0,0 +1,110 @@ +/** + * Scaffold export contract. + * + * `veryfront/scaffold` is the whole point of the parity work: a service that + * creates projects on a user's behalf calls `materializeScaffold()` instead of + * keeping its own copy of a starter. That only holds while the subpath is + * actually declared, and a declared export with nothing asserting it is how + * the coupling breaks silently — a directory move in this repository is enough + * to drop the entry, and every in-repo test keeps passing because they all + * import through relative paths that moved with it. + * + * These tests are that missing enforcement, and they live in the repository + * where a layout change originates. The clean-room half — that the subpath + * resolves from an installed package, through the published `exports` map — + * is step 6 of `scripts/test/npm-install-smoke.sh`; nothing that imports by + * relative path can prove it. + * + * @module templates/scaffold-export.test + */ + +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { readTextFile, stat } from "#veryfront/testing/deno-compat.ts"; +import * as scaffold from "./scaffold.ts"; + +/** The public subpath. Changing it is a breaking change for every consumer. */ +const EXPORT_SUBPATH = "./scaffold"; +const EXPORT_SOURCE = "./templates/scaffold.ts"; + +/** + * The behaviour a consumer outside this repository calls. `materializeScaffold` + * is the reason the subpath exists; the other three are what a caller needs to + * accept a template name from a user before calling it. + */ +const REQUIRED_EXPORTS = [ + "materializeScaffold", + "listScaffoldTemplates", + "resolveScaffoldTemplate", + "SCAFFOLD_TEMPLATE_ALIASES", +] as const; + +async function readDenoExports(): Promise> { + return JSON.parse(await readTextFile("deno.json")).exports; +} + +describe("scaffold export", () => { + it("is a declared public subpath", async () => { + const exports = await readDenoExports(); + + assertEquals( + exports[EXPORT_SUBPATH], + EXPORT_SOURCE, + `${EXPORT_SUBPATH} must be exported from ${EXPORT_SOURCE}`, + ); + }); + + /** + * `deno task build:npm` turns every `exports` entry straight into a dnt entry + * point, so an entry pointing at a file that no longer exists fails the build + * rather than the test suite. Catch it here, where the message says which + * subpath moved. + */ + it("points at a module that exists", async () => { + const exports = await readDenoExports(); + const source = exports[EXPORT_SUBPATH]; + assert(source !== undefined, `${EXPORT_SUBPATH} is not exported`); + + const info = await stat(source.replace(/^\.\//, "")); + assert(info.isFile, `${source} is not a file`); + }); + + it("carries the behaviour a consumer imports it for", () => { + for (const name of REQUIRED_EXPORTS) { + assert( + name in scaffold, + `veryfront/scaffold no longer exports "${name}"`, + ); + } + + assertEquals(typeof scaffold.materializeScaffold, "function"); + assertEquals(typeof scaffold.listScaffoldTemplates, "function"); + assertEquals(typeof scaffold.resolveScaffoldTemplate, "function"); + }); + + it("materializes a project through the exported entry point", async () => { + const { files } = await scaffold.materializeScaffold({ + template: "minimal", + projectName: "export-contract-app", + }); + + const paths = files.map((file) => file.path); + for (const expected of ["package.json", "AGENTS.md", ".gitignore"]) { + assert( + paths.includes(expected), + `a materialized project is missing ${expected}`, + ); + } + + const { files: denoFiles } = await scaffold.materializeScaffold({ + template: "minimal", + projectName: "export-contract-app", + runtime: "deno", + }); + + assert( + denoFiles.some((file) => file.path === "deno.json"), + "a Deno-runtime project is missing deno.json", + ); + }); +}); diff --git a/templates/scaffold-parity.test.ts b/templates/scaffold-parity.test.ts new file mode 100644 index 0000000000..49f8290f56 --- /dev/null +++ b/templates/scaffold-parity.test.ts @@ -0,0 +1,162 @@ +/** + * Scaffold parity gate. + * + * veryfront-issue-inbox #475: a project created outside the CLI must be + * byte-identical to one `veryfront init` creates from the same template. + * Nothing enforced that, because the hosted flow copied its own stored + * starter project instead of reading this repository's templates — so it + * froze at an older era while the CLI templates moved on. + * + * `veryfront/scaffold` is the artifact that closes it: one materializer, two + * consumers. These tests assert the agreement by construction — the CLI's + * on-disk output is compared against what `materializeScaffold` returns for + * the same request, so any future template, generator or alias change is + * covered without a snapshot to update. + * + * @module templates/scaffold-parity.test + */ + +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { makeTempDir, remove } from "#veryfront/testing/deno-compat.ts"; +import { join } from "#veryfront/compat/path/index.ts"; +import { walk } from "#std/fs.ts"; +import { readTextFile } from "#veryfront/testing/deno-compat.ts"; +import { + createProject, + listScaffoldTemplates, + materializeScaffold, + resolveScaffoldTemplate, +} from "../cli/shared/project-creation.ts"; +import { STARTER_TEMPLATE_NAMES } from "./types.ts"; +import type { InitTemplate } from "../cli/commands/init/types.ts"; + +const PROJECT_NAME = "parity-app"; + +/** Read a scaffolded project back off disk as `path -> content`. */ +async function readProject(projectDir: string): Promise> { + const files = new Map(); + for await (const entry of walk(projectDir, { includeDirs: false })) { + const relative = entry.path.slice(projectDir.length + 1).replaceAll("\\", "/"); + files.set(relative, await readTextFile(entry.path)); + } + return files; +} + +/** + * Index the returned files by path. + * + * A `Map` would quietly collapse a path emitted twice, and a caller writing + * the array in order would end up with whichever copy came last, so the + * duplicate is rejected here rather than hidden. + */ +function materializedFiles(files: { path: string; content: string }[]): Map { + const byPath = new Map(); + for (const file of files) { + assertEquals( + byPath.has(file.path), + false, + `materializeScaffold returned "${file.path}" more than once`, + ); + byPath.set(file.path, file.content); + } + return byPath; +} + +describe("scaffold parity", () => { + for (const template of STARTER_TEMPLATE_NAMES) { + it(`materializes exactly what \`veryfront init\` writes: ${template}`, async () => { + const parentDir = await makeTempDir({ prefix: `veryfront-parity-${template}-` }); + try { + await createProject({ + name: PROJECT_NAME, + parentDir, + template: template as InitTemplate, + runtime: "node", + features: [], + integrations: [], + environmentValues: {}, + conflictPolicy: "fail", + installDependencies: false, + initializeGit: false, + includePackageMetadata: true, + }); + + const written = await readProject(join(parentDir, PROJECT_NAME)); + const materialized = materializedFiles( + (await materializeScaffold({ template, projectName: PROJECT_NAME })).files, + ); + + assertEquals( + [...materialized.keys()].sort(), + [...written.keys()].sort(), + `${template}: materialized file list must match what the CLI wrote`, + ); + for (const [path, content] of written) { + assertEquals( + materialized.get(path), + content, + `${template}: ${path} must be byte-identical between the CLI and the materializer`, + ); + } + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + } + + it("writes deno.json only for the deno runtime, on both paths", async () => { + const node = await materializeScaffold({ template: "minimal", projectName: PROJECT_NAME }); + const deno = await materializeScaffold({ + template: "minimal", + projectName: PROJECT_NAME, + runtime: "deno", + }); + + assertEquals(node.files.some((file) => file.path === "deno.json"), false); + assertEquals(deno.files.some((file) => file.path === "deno.json"), true); + }); + + describe("template vocabulary", () => { + it("resolves the hosted 'blank' slug to the CLI's minimal starter", () => { + assertEquals(resolveScaffoldTemplate("blank"), "minimal"); + }); + + it("materializes 'blank' and 'minimal' as the same project", async () => { + const blank = await materializeScaffold({ template: "blank", projectName: PROJECT_NAME }); + const minimal = await materializeScaffold({ + template: "minimal", + projectName: PROJECT_NAME, + }); + + assertEquals(blank.template, minimal.template); + assertEquals(blank.files, minimal.files); + }); + + it("resolves every advertised slug", () => { + for (const slug of listScaffoldTemplates()) { + assertEquals( + resolveScaffoldTemplate(slug) !== null, + true, + `advertised slug "${slug}" must resolve to a template`, + ); + } + }); + + it("rejects an unknown slug instead of scaffolding something else", async () => { + assertEquals(resolveScaffoldTemplate("nope"), null); + await assertRejects(() => materializeScaffold({ template: "nope" })); + }); + + it("rejects a project name the CLI would reject", async () => { + for (const name of ["", " ", "../escape", "nested/name", ".."]) { + await assertRejects( + () => materializeScaffold({ template: "minimal", projectName: name }), + undefined, + undefined, + `"${name}" must be rejected here as well as by \`veryfront init\``, + ); + } + }); + }); +}); diff --git a/templates/scaffold-quality.test.ts b/templates/scaffold-quality.test.ts new file mode 100644 index 0000000000..b633a49e58 --- /dev/null +++ b/templates/scaffold-quality.test.ts @@ -0,0 +1,161 @@ +/** + * Scaffold quality gate. + * + * The issue this guards (veryfront-issue-inbox #475) asks that a freshly + * created project install, run, and build with zero errors and zero lint + * errors. Nothing enforced that: the starter templates are ordinary files + * under `templates/files/`, but the repo's own `deno lint` never sees + * them because they are excluded from the workspace lint — so template code + * could (and did) ship `jsx-button-has-type`, `require-await`, + * `no-explicit-any` and `no-unused-vars` violations that every scaffolded + * project inherited on its first `veryfront lint`. + * + * The same exclusion hid type errors: `step()` was handed an `execute` + * callback `StepOptions` does not accept, `getAgentsAsTools` a list of ids + * where it takes a description map, and `readDir`'s async iterable was + * filtered like an array — three templates failed `tsc --noEmit` on a fresh + * `npm install`. + * + * This scaffolds each starter exactly the way `veryfront init` does, then runs + * the same `deno lint` that `veryfront lint` shells out to, and type-checks + * the agents, tools and workflows against the framework's real declarations — + * so a template can never again reach a user with an error in it. + * + * @module templates/scaffold-quality.test + */ + +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { exists, makeTempDir, remove } from "#veryfront/testing/deno-compat.ts"; +import { fromFileUrl, join } from "#veryfront/compat/path/index.ts"; +import { walk } from "#std/fs.ts"; +import { runCommand } from "#veryfront/compat/process.ts"; +import { createProject } from "../cli/shared/project-creation.ts"; +import { STARTER_TEMPLATE_NAMES } from "./types.ts"; + +interface LintDiagnostic { + code?: string; + filename?: string; + message?: string; + range?: { start?: { line?: number } }; +} + +/** `deno lint --json` payload, narrowed to the fields this gate reports on. */ +interface LintReport { + diagnostics?: LintDiagnostic[]; + errors?: unknown[]; +} + +function describeDiagnostic(diagnostic: LintDiagnostic, projectDir: string): string { + const file = (diagnostic.filename ?? "").split(projectDir).pop() ?? ""; + const line = diagnostic.range?.start?.line ?? 0; + return `${diagnostic.code ?? "lint"} ${file.replace(/^\//, "")}:${line} ${ + diagnostic.message ?? "" + }`; +} + +async function scaffold(template: string, projectDir: string): Promise { + await createProject({ + parentDir: projectDir, + template: template as Parameters[0]["template"], + runtime: "node", + features: [], + integrations: [], + environmentValues: {}, + conflictPolicy: "overwrite", + installDependencies: false, + initializeGit: false, + includePackageMetadata: true, + }); +} + +/** Run the same lint `veryfront lint` runs, and report every diagnostic. */ +async function lintScaffold(projectDir: string): Promise { + const result = await runCommand("deno", { + args: ["lint", "--json"], + cwd: projectDir, + capture: true, + }); + + const stdout = result.stdout ?? ""; + let report: LintReport; + try { + report = JSON.parse(stdout) as LintReport; + } catch { + throw new Error( + `deno lint produced no JSON report (exit ${result.code}): ${result.stderr ?? stdout}`, + ); + } + + return (report.diagnostics ?? []).map((diagnostic) => describeDiagnostic(diagnostic, projectDir)); +} + +/** + * Repo config, so `veryfront/*` resolves to the framework's own declarations. + * + * `fromFileUrl`, not `URL.pathname`: on Windows the latter yields + * `/C:/repo/deno.json`, which `deno` cannot open. + */ +const REPO_CONFIG = fromFileUrl(new URL("../deno.json", import.meta.url)); + +/** Server-side template code: agents, tools, workflows and evals. */ +async function serverSourceFiles(projectDir: string): Promise { + const files: string[] = []; + for (const directory of ["agents", "tools", "workflows", "evals"]) { + const root = join(projectDir, directory); + if (!await exists(root)) continue; + for await (const entry of walk(root, { includeDirs: false, exts: [".ts"] })) { + files.push(entry.path); + } + } + return files.sort(); +} + +/** Type-check a scaffold's server code against the framework declarations. */ +async function typeCheckScaffold(projectDir: string): Promise { + const files = await serverSourceFiles(projectDir); + if (files.length === 0) return ""; + + const result = await runCommand("deno", { + args: ["check", "--config", REPO_CONFIG, ...files], + cwd: projectDir, + capture: true, + }); + + return result.code === 0 ? "" : (result.stderr ?? result.stdout ?? "type check failed"); +} + +describe("scaffolded starter templates", () => { + for (const template of STARTER_TEMPLATE_NAMES) { + it(`type-checks against the framework: ${template}`, async () => { + const projectDir = await makeTempDir({ prefix: `veryfront-types-${template}-` }); + try { + await scaffold(template, projectDir); + assertEquals( + await typeCheckScaffold(projectDir), + "", + `a fresh ${template} project must type-check against the framework it installs`, + ); + } finally { + await remove(projectDir, { recursive: true }).catch(() => {}); + } + }); + + it(`lints clean: ${template}`, async () => { + const projectDir = await makeTempDir({ prefix: `veryfront-scaffold-${template}-` }); + try { + await scaffold(template, projectDir); + const problems = await lintScaffold(projectDir); + assertEquals( + problems, + [], + `\`veryfront lint\` must report nothing on a fresh ${template} project:\n ${ + problems.join("\n ") + }`, + ); + } finally { + await remove(projectDir, { recursive: true }).catch(() => {}); + } + }); + } +}); diff --git a/templates/scaffold.ts b/templates/scaffold.ts new file mode 100644 index 0000000000..d5d53a6747 --- /dev/null +++ b/templates/scaffold.ts @@ -0,0 +1,42 @@ +/** + * Create a Veryfront project from a starter template. + * + * `materializeScaffold()` returns the complete contents of a new project - + * every file `veryfront init` writes, including `package.json`, `AGENTS.md` + * and `.gitignore` - without touching a disk. A service that creates projects + * on a user's behalf can write them wherever it stores project files and get + * a project identical to one scaffolded on the command line. + * + * Templates are addressed by name (`minimal`, `ai-agent`, `docs-agent`, + * `agentic-workflow`, `multi-agent-system`, `coding-agent`, `saas-starter`). + * `listScaffoldTemplates()` enumerates every accepted name and + * `resolveScaffoldTemplate()` reports which starter a name selects. + * + * @module templates/scaffold + * + * @example Create a project and store its files + * ```ts + * import { materializeScaffold } from "veryfront/scaffold"; + * + * const { files } = await materializeScaffold({ + * template: "minimal", + * projectName: "my-app", + * }); + * + * for (const file of files) { + * console.log(file.path, file.content.length); + * } + * ``` + */ + +export { + listScaffoldTemplates, + materializeScaffold, + resolveScaffoldTemplate, + SCAFFOLD_TEMPLATE_ALIASES, +} from "../cli/shared/project-creation.ts"; +export type { + MaterializedScaffold, + MaterializeScaffoldRequest, +} from "../cli/shared/project-creation.ts"; +export type { TemplateFile } from "./types.ts";