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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ and defines the MCP allowlist. `render` is imported by `claude` (for the MCP
URL), by `teardown`, and by `deploy` and `delete`. `blueprint`, `git`,
`images`, `teardown`, and `store` are used by `workflow` and the stages;
`teardown` uses `render` and `blueprint`; `gateway` uses `store`, `policy`,
and `contracts`. `config` and `contracts` are leaves. Adding an edge that
points backwards is a design smell.
and `contracts`. `contracts` is a leaf, and `config` uses only its `slug`
schema. Adding an edge that points backwards is a design smell.

```text
factory.config.ts Directories, branch, plans, asset hosts, model tiers
Expand Down
18 changes: 10 additions & 8 deletions app/blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,15 +365,17 @@ function databaseBlocks(manifest: Manifest, names: ResourceNames): string[] {
}

/**
* Join a manifest-declared rootDir onto the app's repo-relative path. The
* manifest is agent-authored, so tolerate "." meaning the app directory
* itself and a rootDir that already repeats the app path.
* Join a directory of the manifest onto a base directory: a rootDir onto the
* app, or a staticPublishPath onto its service. The manifest is
* agent-authored, so tolerate the two things models get wrong: "." or "./"
* for the base itself, and a path that repeats the base or its end. The
* Blueprint and verify-app both use this, so they agree on each directory.
*/
function joinServiceDir(root: string, rootDir: string): string {
const cleaned = rootDir.replace(/^\.\/+/, "").replace(/\/+$/, "");
if (!cleaned || cleaned === ".") return root;
if (root.endsWith(`/${cleaned}`) || root === cleaned) return root;
return `${root}/${cleaned}`;
export function joinServiceDir(base: string, dir: string): string {
const cleaned = dir.replace(/^\.\/+/, "").replace(/\/+$/, "");
if (!cleaned || cleaned === "." || cleaned === base) return base;
if (base.endsWith(`/${cleaned}`)) return base;
return `${base}/${cleaned}`;
}

/**
Expand Down
5 changes: 3 additions & 2 deletions app/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/** Environment parsing and per-process validation. */
import { slug } from "./contracts.js";

const REPO_PATTERN =
/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
const USER_SLUG_PATTERN = /^[a-z][a-z0-9-]{2,30}$/;

export interface Repository {
owner: string;
Expand Down Expand Up @@ -52,7 +52,8 @@ export function apiKey(): string {
export function uiCredentials(): { username: string; password: string } {
const username = requireEnv("UI_USERNAME");
const password = requireEnv("UI_PASSWORD");
if (!USER_SLUG_PATTERN.test(username)) {
// The gateway gives it to every run of the UI as the namespace `user`.
if (!slug.safeParse(username).success) {
throw new Error("UI_USERNAME must be a lowercase slug");
}
if (password.length < 16) {
Expand Down
3 changes: 2 additions & 1 deletion app/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** Zod schemas for external input, agent output, and the stored spec. */
import { z } from "zod";

const slug = z
/** A user namespace or an app name. It goes into paths and resource names. */
export const slug = z
.string()
.regex(/^[a-z][a-z0-9-]{2,30}$/, "must be a lowercase slug");

Expand Down
17 changes: 3 additions & 14 deletions app/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import { type TaskContext, task } from "@renderinc/sdk/workflows";
import { appPath } from "../factory.config.js";
import { joinServiceDir } from "./blueprint.js";
import {
type Manifest,
type Service,
Expand Down Expand Up @@ -117,7 +118,7 @@ async function verify(
// This check needs no build, so a failed build cannot hide it.
failures.push(...checkStaticSiteEnvVars(service));

const serviceDir = resolveServiceDir(appDir, service.rootDir);
const serviceDir = joinServiceDir(appDir, service.rootDir);

// Run the build command.
const build = await runVerification(sandbox, serviceDir, [
Expand All @@ -131,7 +132,7 @@ async function verify(
}

if (service.kind === "static_site" && service.staticPublishPath) {
const publishDir = resolveServiceDir(
const publishDir = joinServiceDir(
serviceDir,
service.staticPublishPath,
);
Expand Down Expand Up @@ -346,15 +347,3 @@ async function probeService(
body: rest.join("\n"),
};
}

/**
* Join a manifest-declared subdirectory onto a base path. The manifest is
* agent-authored, so tolerate the two things models get wrong: "." or "./"
* meaning "this directory", and repeating the base path they were given.
*/
function resolveServiceDir(base: string, relative: string): string {
const cleaned = relative.replace(/^\.\/+/, "").replace(/\/+$/, "");
if (!cleaned || cleaned === ".") return base;
if (base.endsWith(`/${cleaned}`)) return base;
return `${base}/${cleaned}`;
}
76 changes: 29 additions & 47 deletions scripts/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { factoryConfig } from "../factory.config.js";
import { appsRepo, renderWorkspaceId } from "../app/config.js";
import {
apiKey,
appsRepo,
renderWorkspaceId,
uiCredentials,
} from "../app/config.js";
import { githubToken, usingGitHubApp } from "../app/git.js";
import { RENDER_READ_ONLY_TOOLS } from "../app/policy.js";
import { findBlueprint, renderMcpUrl } from "../app/render.js";
Expand Down Expand Up @@ -52,60 +57,37 @@ async function github<T>(
}
}

function checkConfiguration(): void {
heading("Configuration");

/**
* Run a validator of app/config.ts, the same one that a process runs when it
* starts, and record its message or its error.
*/
function validate(check: () => string, fix: string): void {
try {
const repo = appsRepo();
record({ level: "ok", message: `APPS_REPO is ${repo.fullName}` });
record({ level: "ok", message: check() });
} catch (error) {
record({
level: "fail",
message: "APPS_REPO is missing or malformed",
fix: error instanceof Error ? error.message : "Use owner/repo format.",
message: error instanceof Error ? error.message : String(error),
fix,
});
}
}

const key = process.env.FACTORY_API_KEY?.trim() ?? "";
if (!key) {
record({
level: "fail",
message: "FACTORY_API_KEY is missing",
fix: "Generate one with: openssl rand -hex 32",
});
} else if (key.length < 24) {
record({
level: "fail",
message: `FACTORY_API_KEY is only ${key.length} characters`,
fix: "Use at least 24. Generate with: openssl rand -hex 32",
});
} else {
record({ level: "ok", message: "FACTORY_API_KEY looks strong" });
}
function checkConfiguration(): void {
heading("Configuration");

const uiUsername = process.env.UI_USERNAME?.trim() ?? "";
if (!/^[a-z][a-z0-9-]{2,30}$/.test(uiUsername)) {
record({
level: "fail",
message: "UI_USERNAME is missing or is not a lowercase slug",
fix: "Use 3-31 lowercase letters, numbers, or hyphens; it also namespaces generated apps.",
});
} else {
record({
level: "ok",
message: `UI_USERNAME namespaces apps as ${uiUsername}`,
});
}
const uiPassword = process.env.UI_PASSWORD?.trim() ?? "";
if (uiPassword.length < 16) {
record({
level: "fail",
message: "UI_PASSWORD is missing or shorter than 16 characters",
fix: "Set a strong password on the gateway.",
});
} else {
record({ level: "ok", message: "UI_PASSWORD looks strong" });
}
validate(
() => `APPS_REPO is ${appsRepo().fullName}`,
"Use owner/repo format.",
);
validate(() => {
apiKey();
return "FACTORY_API_KEY looks strong";
}, "Generate one with: openssl rand -hex 32");
validate(
() => `UI_USERNAME namespaces apps as ${uiCredentials().username}`,
"UI_USERNAME needs 3-31 lowercase letters, numbers, or hyphens, and UI_PASSWORD at least 16 characters.",
);

// The gateway needs this to dispatch, not just the workflows host.
present(
Expand Down
25 changes: 25 additions & 0 deletions tests/blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { parse } from "yaml";
import {
appBlueprint,
declaredResources,
joinServiceDir,
resourceNames,
rootBlueprint,
} from "../app/blueprint.js";
Expand Down Expand Up @@ -129,6 +130,30 @@ function parsedServices(yaml: string) {
return parse(yaml).projects[0].environments[0].services;
}

/**
* The Blueprint and verify-app join the same agent-written directories, so
* they must agree on each one.
*/
describe("joinServiceDir", () => {
it.each([
["web", "apps/demo/shop/web"],
["./web/", "apps/demo/shop/web"],
[".", "apps/demo/shop"],
["./", "apps/demo/shop"],
// A model repeats the path that it was given, or its end.
["shop", "apps/demo/shop"],
["apps/demo/shop", "apps/demo/shop"],
])("joins %s onto the app directory", (dir, joined) => {
expect(joinServiceDir("apps/demo/shop", dir)).toBe(joined);
});

it("joins onto an absolute directory, as verify-app does", () => {
const app = "/home/user/repo/apps/demo/shop";
expect(joinServiceDir(app, app)).toBe(app);
expect(joinServiceDir(`${app}/web`, "dist")).toBe(`${app}/web/dist`);
});
});

describe("resourceNames", () => {
// A new default prefix must not rename the resources of an existing app.
it("takes the prefix from the spec, not from the factory configuration", () => {
Expand Down
Loading