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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 44 additions & 18 deletions cli/commands/init/config-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,22 @@ export interface CreatePackageJsonOptions {
}>;
}

export async function createPackageJson(
projectDir: string,
projectName?: string,
options: CreatePackageJsonOptions = {},
): Promise<void> {
const fs = createFileSystem();

// Read any existing package.json (e.g. from template) to merge dependencies
const templateDeps: Record<string, string> = { ...(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, string> } = {},
): string {
const templateDeps: Record<string, string> = {
...(options.dependencies ?? {}),
...(options.existingDependencies ?? {}),
};

// Merge per-integration deps. First declaration wins; collisions are logged.
const integrationDeps: Record<string, string> = {};
Expand All @@ -64,7 +66,6 @@ export async function createPackageJson(
}
}

const dirName = projectDir.split(/[/\\]/).pop();
const veryfrontVersionRange = `^${VERSION}`;
const firstPartyExtensionPackages = options.firstPartyExtensions ?? [];
const requiredExtensionDeps = Object.fromEntries(
Expand All @@ -74,7 +75,7 @@ export async function createPackageJson(
]),
);
const packageJson = {
name: projectName ?? dirName ?? "veryfront-project",
name: projectName,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
version: "0.1.0",
type: "module",
scripts: {
Expand Down Expand Up @@ -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<void> {
const fs = createFileSystem();

// Read any existing package.json (e.g. from template) to merge dependencies
const pkgPath = join(projectDir, "package.json");
let existingDependencies: Record<string, string> | 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"');
Expand Down
10 changes: 9 additions & 1 deletion cli/commands/init/deno-config-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,5 +36,5 @@ export async function createDenoConfig(projectDir: string): Promise<void> {
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());
}
213 changes: 188 additions & 25 deletions cli/shared/project-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ScaffoldAssembly> {
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 = {},
Expand All @@ -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") {
Expand Down Expand Up @@ -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<Record<string, InitTemplate>> = 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<string, string>;
/** 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<MaterializedScaffold> {
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,
};
}
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading