chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,406 @@
import { describe, expect, it, test } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
const repoRoot = path.resolve(__dirname, "..", "..");
const integrationsDir = path.join(repoRoot, "examples", "integrations");
const migratedIntegrations = [
"crewai-flows",
"llamaindex",
"langgraph-fastapi",
"pydantic-ai",
"mcp-apps",
"agent-spec",
"strands-python",
"crewai-crews",
] as const;
const a2aMiddlewareRoot = path.join(integrationsDir, "a2a-middleware");
const appRoots: Record<(typeof migratedIntegrations)[number], string> = {
"crewai-flows": "src/app",
llamaindex: "src/app",
"langgraph-fastapi": "src/app",
"pydantic-ai": "src/app",
"mcp-apps": "app",
"agent-spec": "src/app",
"strands-python": "src/app",
"crewai-crews": "src/app",
};
function readIntegrationFile(
integration: string,
relativePath: string,
): string {
return fs.readFileSync(
path.join(integrationsDir, integration, relativePath),
"utf8",
);
}
function readOptionalIntegrationFile(
integration: string,
relativePath: string,
): string {
const filePath = path.join(integrationsDir, integration, relativePath);
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
}
function readA2AMiddlewareFile(pathFromRoot: string): string {
return fs.readFileSync(path.join(a2aMiddlewareRoot, pathFromRoot), "utf8");
}
describe("batch-2 Intelligence integration migration", () => {
for (const integration of migratedIntegrations) {
it(`${integration} has the env-gated Intelligence runtime route`, () => {
const route = readIntegrationFile(
integration,
`${appRoots[integration]}/api/copilotkit/[[...slug]]/route.ts`,
);
expect(route).toContain("CopilotKitIntelligence");
expect(route).toContain("process.env.COPILOTKIT_LICENSE_TOKEN");
expect(route).toContain(
"licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN",
);
expect(route).toContain('id: "demo-user"');
expect(route).toContain("new InMemoryAgentRunner()");
expect(route).toContain("export const GET = handle(app)");
expect(route).toContain("export const POST = handle(app)");
expect(route).toContain("export const PATCH = handle(app)");
expect(route).toContain("export const DELETE = handle(app)");
});
it(`${integration} forces REST transport for thread routes`, () => {
const layout = readIntegrationFile(
integration,
`${appRoots[integration]}/layout.tsx`,
);
const page = readIntegrationFile(
integration,
`${appRoots[integration]}/page.tsx`,
);
expect(`${layout}\n${page}`).toContain("useSingleEndpoint={false}");
});
it(`${integration} wires the threads drawer into the chat thread context`, () => {
const page = readIntegrationFile(
integration,
`${appRoots[integration]}/page.tsx`,
);
expect(page).toContain("ThreadsDrawer");
expect(page).toContain("ThreadsPanelGate");
expect(page).toContain("CopilotChatConfigurationProvider");
expect(page).toContain("threadId");
expect(page).toContain("onThreadChange={setThreadId}");
if (integration === "mcp-apps") {
expect(page).toContain('key={threadId ?? "new-thread"}');
expect(page).toContain("threadId={threadId}");
const drawer = readIntegrationFile(
integration,
"app/components/threads-drawer/threads-drawer.tsx",
);
expect(drawer).toContain("onThreadChange(undefined)");
expect(drawer).not.toContain("crypto.randomUUID()");
}
});
it(`${integration} exposes the client-safe threads enabled gate`, () => {
const nextConfig = readIntegrationFile(integration, "next.config.ts");
expect(nextConfig).toContain("NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED");
expect(nextConfig).toContain("process.env.COPILOTKIT_LICENSE_TOKEN");
});
it(`${integration} documents the local Intelligence environment`, () => {
const envExample = readOptionalIntegrationFile(
integration,
".env.example",
);
expect(envExample).toContain("COPILOTKIT_LICENSE_TOKEN");
expect(envExample).toContain("INTELLIGENCE_API_KEY");
expect(envExample).toContain("INTELLIGENCE_API_URL");
expect(envExample).toContain("INTELLIGENCE_GATEWAY_WS_URL");
});
it(`${integration} pins CopilotKit packages to the threads-capable release`, () => {
const packageJson = JSON.parse(
readIntegrationFile(integration, "package.json"),
) as { dependencies?: Record<string, string> };
expect(packageJson.dependencies?.["@copilotkit/react-core"]).toBe(
"1.59.3",
);
expect(packageJson.dependencies?.["@copilotkit/runtime"]).toBe("1.59.3");
});
}
});
test("a2a-middleware runtime route is gated for Intelligence threads", () => {
const route = readA2AMiddlewareFile(
"app/api/copilotkit/[[...slug]]/route.ts",
);
expect(route).toContain("CopilotKitIntelligence");
expect(route).toContain(
"class RuntimeA2AMiddlewareAgent extends A2AMiddlewareAgent",
);
expect(route).toContain("const isolatedAgent = new A2AMiddlewareAgent");
expect(route).toContain("new HttpAgent({");
expect(route).toContain("isolatedAgent.setMessages(parameters.messages)");
expect(route).toContain("return isolatedAgent.runAgent(");
expect(route).toContain("process.env.COPILOTKIT_LICENSE_TOKEN");
expect(route).toContain("process.env.INTELLIGENCE_API_KEY");
expect(route).toContain("process.env.INTELLIGENCE_API_URL");
expect(route).toContain("process.env.INTELLIGENCE_GATEWAY_WS_URL");
expect(route).toContain('id: "demo-user"');
expect(route).toContain("licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN");
expect(route).toContain(": { runner: new InMemoryAgentRunner() }");
expect(route).toContain("export const GET = handle(app);");
expect(route).toContain("export const POST = handle(app);");
expect(route).toContain("export const PATCH = handle(app);");
expect(route).toContain("export const DELETE = handle(app);");
});
test("a2a-middleware preserves its three-agent URL configuration", () => {
const route = readA2AMiddlewareFile(
"app/api/copilotkit/[[...slug]]/route.ts",
);
expect(route).toContain("process.env.RESEARCH_AGENT_URL");
expect(route).toContain("process.env.ANALYSIS_AGENT_URL");
expect(route).toContain("process.env.ORCHESTRATOR_URL");
expect(route).toContain('agentId: "a2a_chat"');
expect(route).toContain("agentUrls: [researchAgentUrl, analysisAgentUrl]");
expect(route).toContain("orchestrationAgentUrl: orchestratorUrl");
});
test("a2a-middleware page uses REST transport for Threads APIs", () => {
const page = readA2AMiddlewareFile("app/page.tsx");
expect(page).toContain('runtimeUrl="/api/copilotkit"');
expect(page).toContain("useSingleEndpoint={false}");
expect(page).toContain('agentId="a2a_chat"');
expect(page).toContain("ThreadsDrawer");
expect(page).toContain("ThreadsPanelGate");
expect(page).toContain("CopilotChatConfigurationProvider");
expect(page).toContain("const [threadId, setThreadId]");
expect(page).toContain("threadId={threadId}");
});
test("a2a-middleware chat keeps A2A visualization tools inside the configured chat", () => {
const chat = readA2AMiddlewareFile("components/chat.tsx");
expect(chat).toContain("useFrontendTool");
expect(chat).toContain('name: "send_message_to_a2a_agent"');
expect(chat).toContain("MessageToA2A");
expect(chat).toContain("MessageFromA2A");
expect(chat).not.toContain("<CopilotKit");
});
test("a2a-middleware exposes local Intelligence env documentation", () => {
const envExample = readA2AMiddlewareFile(".env.example");
expect(envExample).toContain("GOOGLE_API_KEY=");
expect(envExample).toContain("OPENAI_API_KEY=");
expect(envExample).toContain("COPILOTKIT_LICENSE_TOKEN=");
expect(envExample).toContain("INTELLIGENCE_API_KEY=");
expect(envExample).toContain("INTELLIGENCE_API_URL=http://localhost:4201");
expect(envExample).toContain(
"INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401",
);
});
test("a2a-middleware package is pinned to the Intelligence-ready CopilotKit SDK", () => {
const packageJson = JSON.parse(readA2AMiddlewareFile("package.json")) as {
dependencies: Record<string, string>;
};
expect(packageJson.dependencies["@copilotkit/react-core"]).toBe("1.59.3");
expect(packageJson.dependencies["@copilotkit/runtime"]).toBe("1.59.3");
expect(packageJson.dependencies["lucide-react"]).toBeDefined();
});
test("a2a-middleware Next config enables the Threads feature flag", () => {
const nextConfig = readA2AMiddlewareFile("next.config.ts");
expect(nextConfig).toContain('output: "standalone"');
expect(nextConfig).toContain("NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED");
expect(nextConfig).toContain("process.env");
expect(nextConfig).toContain("COPILOTKIT_LICENSE_TOKEN");
});
const a2aA2uiRoot = path.join(integrationsDir, "a2a-a2ui");
function readA2AA2uiFile(pathFromRoot: string): string {
return fs.readFileSync(path.join(a2aA2uiRoot, pathFromRoot), "utf8");
}
test("a2a-a2ui runtime route is gated for Intelligence threads", () => {
const route = readA2AA2uiFile("app/api/copilotkit/[[...slug]]/route.tsx");
expect(route).toContain("CopilotKitIntelligence");
expect(route).toContain("class RuntimeA2AAgent extends A2AAgent");
expect(route).toContain("const isolatedAgent = new A2AAgent");
expect(route).toContain("isolatedAgent.setMessages(parameters.messages)");
expect(route).toContain("return isolatedAgent.runAgent(");
expect(route).toContain("process.env.COPILOTKIT_LICENSE_TOKEN");
expect(route).toContain("process.env.INTELLIGENCE_API_KEY");
expect(route).toContain("process.env.INTELLIGENCE_API_URL");
expect(route).toContain("process.env.INTELLIGENCE_GATEWAY_WS_URL");
expect(route).toContain('id: "demo-user"');
expect(route).toContain("licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN");
expect(route).toContain(": { runner: new InMemoryAgentRunner() }");
expect(route).toContain("a2ui: {}");
expect(route).toContain("export const GET = handle(app);");
expect(route).toContain("export const POST = handle(app);");
expect(route).toContain("export const PATCH = handle(app);");
expect(route).toContain("export const DELETE = handle(app);");
});
test("a2a-a2ui page uses REST transport for Threads APIs", () => {
const page = readA2AA2uiFile("app/page.tsx");
expect(page).toContain('runtimeUrl="/api/copilotkit"');
expect(page).toContain('agentId="default"');
expect(page).toContain("useSingleEndpoint={false}");
expect(page).toContain("a2ui={{ theme }}");
expect(page).toContain("const activityRenderers = [a2uiV08Renderer];");
expect(page).toContain("renderActivityMessages={activityRenderers}");
});
test("a2a-a2ui page wires a threads drawer into the active chat thread", () => {
const page = readA2AA2uiFile("app/page.tsx");
expect(page).toContain("ThreadsDrawer");
expect(page).toContain("ThreadsPanelGate");
expect(page).toContain("CopilotChatConfigurationProvider");
expect(page).toContain("const [threadId, setThreadId]");
expect(page).toContain('agentId="default"');
expect(page).toContain("threadId={threadId}");
});
test("a2a-a2ui exposes local Intelligence env documentation", () => {
const envExample = readA2AA2uiFile(".env.example");
const gitignore = readA2AA2uiFile(".gitignore");
expect(envExample).toContain("OPENAI_API_KEY=");
expect(envExample).toContain("COPILOTKIT_LICENSE_TOKEN=");
expect(envExample).toContain("INTELLIGENCE_API_KEY=");
expect(envExample).toContain("INTELLIGENCE_API_URL=http://localhost:4201");
expect(envExample).toContain(
"INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401",
);
expect(gitignore).toContain("!.env.example");
});
test("a2a-a2ui package is pinned to the Intelligence-ready CopilotKit SDK", () => {
const packageJson = JSON.parse(readA2AA2uiFile("package.json")) as {
dependencies: Record<string, string>;
};
expect(packageJson.dependencies["@copilotkit/a2ui-renderer"]).toBe("1.59.3");
expect(packageJson.dependencies["@copilotkit/react-core"]).toBe("1.59.3");
expect(packageJson.dependencies["@copilotkit/runtime"]).toBe("1.59.3");
expect(packageJson.dependencies["lucide-react"]).toBeDefined();
});
test("a2a-a2ui Next config enables the Threads feature flag", () => {
const nextConfig = readA2AA2uiFile("next.config.js");
expect(nextConfig).toContain('output: "standalone"');
expect(nextConfig).toContain(
"NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN",
);
});
const agentcoreRoot = path.join(integrationsDir, "agentcore");
function readAgentcoreFile(pathFromRoot: string): string {
return fs.readFileSync(path.join(agentcoreRoot, pathFromRoot), "utf8");
}
describe("agentcore Intelligence integration migration", () => {
it("gates the Hono runtime bridge with CopilotKit Intelligence", () => {
const runtime = readAgentcoreFile(
"infra-cdk/lambdas/copilotkit-runtime/src/runtime.ts",
);
expect(runtime).toContain("CopilotKitIntelligence");
expect(runtime).toContain("process.env.COPILOTKIT_LICENSE_TOKEN");
expect(runtime).toContain("process.env.INTELLIGENCE_API_KEY");
expect(runtime).toContain("process.env.INTELLIGENCE_API_URL");
expect(runtime).toContain("process.env.INTELLIGENCE_GATEWAY_WS_URL");
expect(runtime).toContain(
"licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN",
);
expect(runtime).toContain('id: "demo-user"');
expect(runtime).toContain(": { runner: new AgentCoreRunner() }");
expect(runtime).toContain('basePath: "/copilotkit"');
});
it("forces REST transport and threads context in the Vite frontend", () => {
const chat = readAgentcoreFile(
"frontend/src/components/chat/CopilotKit/index.tsx",
);
expect(chat).toContain("useSingleEndpoint={false}");
expect(chat).toContain("ThreadsDrawer");
expect(chat).toContain("ThreadsPanelGate");
expect(chat).toContain("CopilotChatConfigurationProvider");
expect(chat).toContain("const [threadId, setThreadId]");
expect(chat).toContain("threadId={threadId}");
expect(chat).toContain("runtimeUrl={runtimeUrl}");
expect(chat).toContain("headers={headers}");
});
it("exposes the client-safe threads enabled gate for Vite", () => {
const viteConfig = readAgentcoreFile("frontend/vite.config.ts");
const lockedState = readAgentcoreFile(
"frontend/src/components/threads-drawer/locked-state.tsx",
);
expect(viteConfig).toContain("VITE_COPILOTKIT_THREADS_ENABLED");
expect(viteConfig).toContain("process.env.COPILOTKIT_LICENSE_TOKEN");
expect(lockedState).toContain(
"import.meta.env.VITE_COPILOTKIT_THREADS_ENABLED",
);
});
it("documents and wires local Intelligence environment variables", () => {
const dockerEnv = readAgentcoreFile("docker/.env.example");
const compose = readAgentcoreFile("docker/docker-compose.yml");
for (const envName of [
"COPILOTKIT_LICENSE_TOKEN",
"INTELLIGENCE_API_KEY",
"INTELLIGENCE_API_URL",
"INTELLIGENCE_GATEWAY_WS_URL",
]) {
expect(dockerEnv).toContain(envName);
expect(compose).toContain(envName);
}
});
it("pins AgentCore frontend and runtime packages to threads-capable versions", () => {
const frontendPackageJson = JSON.parse(
readAgentcoreFile("frontend/package.json"),
) as { dependencies?: Record<string, string> };
const runtimePackageJson = JSON.parse(
readAgentcoreFile("infra-cdk/lambdas/copilotkit-runtime/package.json"),
) as { dependencies?: Record<string, string> };
expect(frontendPackageJson.dependencies?.["@copilotkit/react-core"]).toBe(
"1.59.3",
);
expect(runtimePackageJson.dependencies?.["@copilotkit/runtime"]).toBe(
"1.59.3",
);
expect(runtimePackageJson.dependencies?.["@ag-ui/client"]).toBe("0.0.53");
});
});
@@ -0,0 +1,234 @@
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
syncPluginSkills,
RESERVED_LIFECYCLE_SLUGS,
} from "../sync-plugin-skills.js";
// Fixture helper — builds a miniature repo layout inside a tmpdir that mimics
// the CopilotKit monorepo shape.
async function makeRepo(root: string) {
const pkgRoot = join(root, "packages");
// Two package meta-skills — deliberately different shapes to exercise
// the recursive-copy path and the single-file path.
await mkdir(join(pkgRoot, "runtime/skills/runtime/references"), {
recursive: true,
});
await writeFile(
join(pkgRoot, "runtime/skills/runtime/SKILL.md"),
"---\nname: runtime\n---\n# Runtime\n",
);
await writeFile(
join(pkgRoot, "runtime/skills/runtime/references/setup-endpoint.md"),
"# Setup\n",
);
await mkdir(join(pkgRoot, "a2ui-renderer/skills/a2ui-renderer"), {
recursive: true,
});
await writeFile(
join(pkgRoot, "a2ui-renderer/skills/a2ui-renderer/SKILL.md"),
"---\nname: a2ui-renderer\n---\n# A2UI\n",
);
// Pre-existing standalone skill at the mirror root — must be left alone.
await mkdir(join(root, "skills/copilotkit-setup"), { recursive: true });
await writeFile(
join(root, "skills/copilotkit-setup/SKILL.md"),
"---\nname: copilotkit-setup\n---\n# Setup\n",
);
}
describe("syncPluginSkills", () => {
let repo: string;
beforeEach(async () => {
repo = await mkdtemp(join(tmpdir(), "ck-plugin-sync-"));
});
afterEach(async () => {
await rm(repo, { recursive: true, force: true });
});
it("copies package skill SKILL.md and references into the mirror", async () => {
await makeRepo(repo);
const result = await syncPluginSkills({ cwd: repo, mode: "write" });
expect(result.exitCode).toBe(0);
const runtimeSkill = await readFile(
join(repo, "skills/runtime/SKILL.md"),
"utf8",
);
expect(runtimeSkill).toBe("---\nname: runtime\n---\n# Runtime\n");
const runtimeRef = await readFile(
join(repo, "skills/runtime/references/setup-endpoint.md"),
"utf8",
);
expect(runtimeRef).toBe("# Setup\n");
const a2uiSkill = await readFile(
join(repo, "skills/a2ui-renderer/SKILL.md"),
"utf8",
);
expect(a2uiSkill).toBe("---\nname: a2ui-renderer\n---\n# A2UI\n");
});
it("does not modify pre-existing standalone skills", async () => {
await makeRepo(repo);
await syncPluginSkills({ cwd: repo, mode: "write" });
const standalone = await readFile(
join(repo, "skills/copilotkit-setup/SKILL.md"),
"utf8",
);
expect(standalone).toBe("---\nname: copilotkit-setup\n---\n# Setup\n");
});
it("errors with exit code 2 if a package skill collides with a reserved lifecycle slug", async () => {
const pkgRoot = join(repo, "packages");
await mkdir(join(pkgRoot, "rogue/skills/copilotkit-setup"), {
recursive: true,
});
await writeFile(
join(pkgRoot, "rogue/skills/copilotkit-setup/SKILL.md"),
"collision\n",
);
const result = await syncPluginSkills({ cwd: repo, mode: "write" });
expect(result.exitCode).toBe(2);
expect(result.message).toMatch(/collides with reserved lifecycle slug/);
});
it("check mode returns exitCode 0 when mirror is in sync", async () => {
await makeRepo(repo);
await syncPluginSkills({ cwd: repo, mode: "write" });
const result = await syncPluginSkills({ cwd: repo, mode: "check" });
expect(result.exitCode).toBe(0);
});
it("check mode returns exitCode 1 when mirror has drifted", async () => {
await makeRepo(repo);
await syncPluginSkills({ cwd: repo, mode: "write" });
// Simulate a maintainer editing the package source without re-running sync.
await writeFile(
join(repo, "packages/runtime/skills/runtime/SKILL.md"),
"---\nname: runtime\n---\n# Runtime (edited)\n",
);
const result = await syncPluginSkills({ cwd: repo, mode: "check" });
expect(result.exitCode).toBe(1);
expect(result.message).toMatch(/drift detected/i);
expect(result.message).toContain("skills/runtime/SKILL.md");
});
it("check mode flags orphan files in the mirror (e.g., skill deleted from source)", async () => {
await makeRepo(repo);
await syncPluginSkills({ cwd: repo, mode: "write" });
await rm(join(repo, "packages/a2ui-renderer"), { recursive: true });
const result = await syncPluginSkills({ cwd: repo, mode: "check" });
expect(result.exitCode).toBe(1);
expect(result.message).toMatch(/orphan/i);
expect(result.message).toContain("skills/a2ui-renderer");
});
it("exports the reserved lifecycle slug set", () => {
expect(RESERVED_LIFECYCLE_SLUGS).toContain("copilotkit-setup");
expect(RESERVED_LIFECYCLE_SLUGS).toContain("copilotkit-self-update");
expect(RESERVED_LIFECYCLE_SLUGS.size).toBe(8);
});
// Version sync — the plugin version tracks packages/runtime/package.json.
async function addVersionFixtures(
repoRoot: string,
runtimePkgVersion: string,
initialPluginVersion: string,
) {
await writeFile(
join(repoRoot, "packages/runtime/package.json"),
JSON.stringify(
{ name: "@copilotkit/runtime", version: runtimePkgVersion },
null,
2,
) + "\n",
);
await mkdir(join(repoRoot, ".claude-plugin"), { recursive: true });
await writeFile(
join(repoRoot, ".claude-plugin/plugin.json"),
JSON.stringify(
{ name: "copilotkit", version: initialPluginVersion },
null,
2,
) + "\n",
);
await writeFile(
join(repoRoot, ".claude-plugin/marketplace.json"),
JSON.stringify(
{
name: "copilotkit",
metadata: { version: initialPluginVersion },
plugins: [
{ name: "copilotkit", source: "./", version: initialPluginVersion },
],
},
null,
2,
) + "\n",
);
}
it("write mode copies runtime package.json version into plugin.json and marketplace.json", async () => {
await makeRepo(repo);
await addVersionFixtures(repo, "1.56.2", "0.0.0");
await syncPluginSkills({ cwd: repo, mode: "write" });
const plugin = JSON.parse(
await readFile(join(repo, ".claude-plugin/plugin.json"), "utf8"),
);
const market = JSON.parse(
await readFile(join(repo, ".claude-plugin/marketplace.json"), "utf8"),
);
expect(plugin.version).toBe("1.56.2");
expect(market.plugins[0].version).toBe("1.56.2");
// metadata.version tracks the runtime package too — both marketplace fields
// must move together so neither rots independently.
expect(market.metadata.version).toBe("1.56.2");
});
it("write mode re-syncs marketplace.json metadata.version when it lags plugins[0].version", async () => {
await makeRepo(repo);
await addVersionFixtures(repo, "1.56.2", "1.56.2");
// Simulate the historical drift: plugins[0] was bumped by an earlier sync
// but metadata.version was left behind because it was unmanaged.
const marketPath = join(repo, ".claude-plugin/marketplace.json");
const market = JSON.parse(await readFile(marketPath, "utf8"));
market.metadata.version = "1.55.0";
await writeFile(marketPath, JSON.stringify(market, null, 2) + "\n");
const drift = await syncPluginSkills({ cwd: repo, mode: "check" });
expect(drift.exitCode).toBe(1);
expect(drift.message).toMatch(/metadata\.version/i);
expect(drift.message).toContain("1.55.0");
await syncPluginSkills({ cwd: repo, mode: "write" });
const fixed = JSON.parse(await readFile(marketPath, "utf8"));
expect(fixed.metadata.version).toBe("1.56.2");
expect(fixed.plugins[0].version).toBe("1.56.2");
});
it("check mode detects plugin.json version drift", async () => {
await makeRepo(repo);
await addVersionFixtures(repo, "1.56.2", "0.0.0");
await syncPluginSkills({ cwd: repo, mode: "write" });
// Simulate a maintainer bumping the package but forgetting to run sync.
await writeFile(
join(repo, "packages/runtime/package.json"),
JSON.stringify(
{ name: "@copilotkit/runtime", version: "1.57.0" },
null,
2,
) + "\n",
);
const result = await syncPluginSkills({ cwd: repo, mode: "check" });
expect(result.exitCode).toBe(1);
expect(result.message).toMatch(/version.*drift/i);
expect(result.message).toContain("1.57.0");
});
});
@@ -0,0 +1,283 @@
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
loadAllowlist,
stripProviderPrefix,
looksLikeModelName,
extractModelNames,
validateFiles,
} from "../validate-doc-model-names";
// ---------------------------------------------------------------------------
// stripProviderPrefix
// ---------------------------------------------------------------------------
describe("stripProviderPrefix", () => {
it("strips known provider prefixes", () => {
expect(stripProviderPrefix("openai/gpt-5.4")).toBe("gpt-5.4");
expect(stripProviderPrefix("anthropic/claude-sonnet-4")).toBe(
"claude-sonnet-4",
);
expect(stripProviderPrefix("google/gemini-2.5-pro")).toBe("gemini-2.5-pro");
expect(stripProviderPrefix("cohere/command-r-plus")).toBe("command-r-plus");
expect(stripProviderPrefix("meta/llama-4-scout")).toBe("llama-4-scout");
expect(stripProviderPrefix("mistral/mistral-large")).toBe("mistral-large");
expect(stripProviderPrefix("azure/gpt-4o")).toBe("gpt-4o");
expect(stripProviderPrefix("bedrock/claude-sonnet-4")).toBe(
"claude-sonnet-4",
);
expect(stripProviderPrefix("vertex/gemini-2.5-flash")).toBe(
"gemini-2.5-flash",
);
});
it("returns the name unchanged when no prefix matches", () => {
expect(stripProviderPrefix("gpt-5.4")).toBe("gpt-5.4");
expect(stripProviderPrefix("claude-sonnet-4")).toBe("claude-sonnet-4");
});
});
// ---------------------------------------------------------------------------
// looksLikeModelName
// ---------------------------------------------------------------------------
describe("looksLikeModelName", () => {
it("recognizes known model prefixes", () => {
expect(looksLikeModelName("gpt-5.4")).toBe(true);
expect(looksLikeModelName("claude-sonnet-4")).toBe(true);
expect(looksLikeModelName("gemini-2.5-pro")).toBe(true);
expect(looksLikeModelName("o1")).toBe(true);
expect(looksLikeModelName("o1-mini")).toBe(true);
expect(looksLikeModelName("o3-mini")).toBe(true);
expect(looksLikeModelName("o4-mini")).toBe(true);
expect(looksLikeModelName("command-r-plus")).toBe(true);
expect(looksLikeModelName("command-a")).toBe(true);
expect(looksLikeModelName("mistral-large")).toBe(true);
expect(looksLikeModelName("llama-4-scout")).toBe(true);
});
it("rejects non-model strings", () => {
expect(looksLikeModelName("react")).toBe(false);
expect(looksLikeModelName("next.js")).toBe(false);
expect(looksLikeModelName("typescript")).toBe(false);
expect(looksLikeModelName("")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// extractModelNames
// ---------------------------------------------------------------------------
describe("extractModelNames", () => {
it('extracts model from model="..." in fenced code block', () => {
const content = [
"Some text",
"```python",
'ChatOpenAI(model="gpt-5.4-mini")',
"```",
].join("\n");
const results = extractModelNames(content);
expect(results).toEqual([{ model: "gpt-5.4-mini", line: 3 }]);
});
it('extracts model from model: "..." pattern', () => {
const content = [
"```tsx",
'const config = { model: "claude-sonnet-4" };',
"```",
].join("\n");
const results = extractModelNames(content);
expect(results).toEqual([{ model: "claude-sonnet-4", line: 2 }]);
});
it('extracts model from "model": "..." JSON pattern', () => {
const content = [
"```json",
"{",
' "model": "gemini-2.5-flash"',
"}",
"```",
].join("\n");
const results = extractModelNames(content);
expect(results).toEqual([{ model: "gemini-2.5-flash", line: 3 }]);
});
it("extracts model from single-quoted values", () => {
const content = ["```python", "model='gpt-4o'", "```"].join("\n");
const results = extractModelNames(content);
expect(results).toEqual([{ model: "gpt-4o", line: 2 }]);
});
it("extracts model from inline code", () => {
const content = 'Use `model="gpt-5.4"` for best results.';
const results = extractModelNames(content);
expect(results).toEqual([{ model: "gpt-5.4", line: 1 }]);
});
it("strips provider prefixes from model names", () => {
const content = ["```tsx", 'model="openai/gpt-5.4-mini"', "```"].join("\n");
const results = extractModelNames(content);
expect(results).toEqual([{ model: "gpt-5.4-mini", line: 2 }]);
});
it("handles bare provider-prefixed names", () => {
const content = ["```", "openai/gpt-4o-mini", "```"].join("\n");
const results = extractModelNames(content);
expect(results).toEqual([{ model: "gpt-4o-mini", line: 2 }]);
});
it("extracts multiple models from one file", () => {
const content = [
"```python",
'a = ChatOpenAI(model="gpt-5.4")',
'b = ChatAnthropic(model="claude-sonnet-4")',
"```",
].join("\n");
const results = extractModelNames(content);
expect(results).toHaveLength(2);
expect(results[0].model).toBe("gpt-5.4");
expect(results[1].model).toBe("claude-sonnet-4");
});
it("ignores text outside code blocks", () => {
const content = [
'We recommend model="gpt-5.4" for production.',
"",
"This is plain text, not code.",
].join("\n");
// No fenced block, no inline code — nothing extracted
const results = extractModelNames(content);
expect(results).toEqual([]);
});
it("ignores empty and whitespace-only strings", () => {
const content = ["```", 'model=""', "model=' '", "```"].join("\n");
const results = extractModelNames(content);
expect(results).toEqual([]);
});
it("does not extract non-model strings from code blocks", () => {
const content = [
"```tsx",
'const name = "react-component";',
'import something from "next/router";',
"```",
].join("\n");
const results = extractModelNames(content);
expect(results).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// loadAllowlist
// ---------------------------------------------------------------------------
describe("loadAllowlist", () => {
it("loads all model names from allowlist JSON", () => {
const allowlistPath = path.resolve(
__dirname,
"../../showcase/shell-docs/model-allowlist.json",
);
const allowed = loadAllowlist(allowlistPath);
expect(allowed.has("gpt-5.4")).toBe(true);
expect(allowed.has("claude-sonnet-4")).toBe(true);
expect(allowed.has("gemini-2.5-pro")).toBe(true);
expect(allowed.has("command-r-plus")).toBe(true);
expect(allowed.has("llama-4-scout")).toBe(true);
});
it("excludes the _comment field", () => {
const allowlistPath = path.resolve(
__dirname,
"../../showcase/shell-docs/model-allowlist.json",
);
const allowed = loadAllowlist(allowlistPath);
// _comment value should not be in the set
expect(
allowed.has(
"Maintained list of valid AI model names for docs. Update when providers release new models. CI validates docs against this list.",
),
).toBe(false);
});
});
// ---------------------------------------------------------------------------
// validateFiles (integration)
// ---------------------------------------------------------------------------
describe("validateFiles", () => {
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "model-validate-"));
}
it("returns no violations when all models are in the allowlist", () => {
const dir = createTempDir();
const allowlist = path.join(dir, "allowlist.json");
fs.writeFileSync(
allowlist,
JSON.stringify({ openai: ["gpt-5.4"], anthropic: ["claude-sonnet-4"] }),
);
fs.writeFileSync(
path.join(dir, "test.mdx"),
["```python", 'ChatOpenAI(model="gpt-5.4")', "```"].join("\n"),
);
const violations = validateFiles(dir, allowlist);
expect(violations).toEqual([]);
fs.rmSync(dir, { recursive: true });
});
it("flags model names not in the allowlist", () => {
const dir = createTempDir();
const allowlist = path.join(dir, "allowlist.json");
fs.writeFileSync(allowlist, JSON.stringify({ openai: ["gpt-5.4"] }));
fs.writeFileSync(
path.join(dir, "test.mdx"),
["```python", 'model="gpt-99"', "```"].join("\n"),
);
const violations = validateFiles(dir, allowlist);
expect(violations).toHaveLength(1);
expect(violations[0].model).toBe("gpt-99");
expect(violations[0].file).toBe("test.mdx");
fs.rmSync(dir, { recursive: true });
});
it("scans subdirectories for .mdx files", () => {
const dir = createTempDir();
const sub = path.join(dir, "guides");
fs.mkdirSync(sub);
const allowlist = path.join(dir, "allowlist.json");
fs.writeFileSync(allowlist, JSON.stringify({ openai: ["gpt-5.4"] }));
fs.writeFileSync(
path.join(sub, "deep.mdx"),
["```", 'model="gpt-unknown"', "```"].join("\n"),
);
const violations = validateFiles(dir, allowlist);
expect(violations).toHaveLength(1);
expect(violations[0].file).toBe("guides/deep.mdx");
fs.rmSync(dir, { recursive: true });
});
});
@@ -0,0 +1,201 @@
import { afterEach, describe, expect, it } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
validatePins,
formatViolations,
} from "../validate-integration-pins.js";
function makePkg(deps: Record<string, string>): string {
return JSON.stringify({ name: "fixture", dependencies: deps });
}
function setupFixture(
integrations: Record<string, Record<string, string>>,
): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "validate-pins-"));
for (const [name, deps] of Object.entries(integrations)) {
const dir = path.join(root, name);
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, "package.json"), makePkg(deps));
}
return root;
}
describe("validatePins", () => {
let fixtureDir: string;
afterEach(() => {
if (fixtureDir) fs.rmSync(fixtureDir, { recursive: true, force: true });
});
const ENFORCED = new Set(["adk"]);
it("returns no violations when every enforced integration matches the expected version", () => {
fixtureDir = setupFixture({
adk: { "@copilotkit/react-core": "1.56.4" },
});
const violations = validatePins({
expectedVersion: "1.56.4",
integrationsDir: fixtureDir,
enforced: ENFORCED,
});
expect(violations).toEqual([]);
});
it("flags stale exact-pinned versions in enforced integrations", () => {
fixtureDir = setupFixture({
adk: { "@copilotkit/react-core": "1.55.2" },
});
const violations = validatePins({
expectedVersion: "1.56.4",
integrationsDir: fixtureDir,
enforced: ENFORCED,
});
expect(violations).toHaveLength(1);
expect(violations[0].reason).toBe("stale");
});
it("flags floating dist-tag pins like 'latest' and 'next'", () => {
fixtureDir = setupFixture({
adk: { "@copilotkit/react-core": "latest" },
});
const violations = validatePins({
expectedVersion: "1.56.4",
integrationsDir: fixtureDir,
enforced: ENFORCED,
});
expect(violations).toHaveLength(1);
expect(violations[0].reason).toBe("floating-tag");
});
it("ignores integrations not on the allowlist (the 14 others left for QA)", () => {
fixtureDir = setupFixture({
adk: { "@copilotkit/react-core": "1.56.4" },
mastra: { "@copilotkit/react-core": "1.55.2" },
"mcp-apps": { "@copilotkit/runtime": "1.52.1" },
"a2a-middleware": { "@copilotkit/react-core": "latest" },
});
const violations = validatePins({
expectedVersion: "1.56.4",
integrationsDir: fixtureDir,
enforced: ENFORCED,
});
expect(violations).toEqual([]);
});
it("ignores intentional pre-release pins (e.g. ag-ui pre-release tags)", () => {
fixtureDir = setupFixture({
adk: {
"@copilotkit/react-core": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
},
});
const violations = validatePins({
expectedVersion: "1.56.4",
integrationsDir: fixtureDir,
enforced: ENFORCED,
});
expect(violations).toEqual([]);
});
it("ignores non-@copilotkit dependencies entirely", () => {
fixtureDir = setupFixture({
adk: {
"@copilotkit/react-core": "1.56.4",
next: "16.1.1",
"@ag-ui/client": "0.0.52",
},
});
const violations = validatePins({
expectedVersion: "1.56.4",
integrationsDir: fixtureDir,
enforced: ENFORCED,
});
expect(violations).toEqual([]);
});
it("checks devDependencies too", () => {
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-pins-dev-"));
const dir = path.join(fixtureDir, "adk");
fs.mkdirSync(dir);
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({
name: "adk",
devDependencies: { "@copilotkit/react-core": "1.55.2" },
}),
);
const violations = validatePins({
expectedVersion: "1.56.4",
integrationsDir: fixtureDir,
enforced: ENFORCED,
});
expect(violations).toHaveLength(1);
expect(violations[0].dep).toBe("@copilotkit/react-core");
});
it("skips integrations without a package.json", () => {
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-pins-skip-"));
fs.mkdirSync(path.join(fixtureDir, "adk"));
const violations = validatePins({
expectedVersion: "1.56.4",
integrationsDir: fixtureDir,
enforced: ENFORCED,
});
expect(violations).toEqual([]);
});
});
describe("formatViolations", () => {
it("returns an empty string when there are no violations", () => {
expect(formatViolations([], "1.56.4")).toBe("");
});
it("formats violations with reason, dep, and expected version", () => {
const out = formatViolations(
[
{
integration: "adk",
dep: "@copilotkit/react-core",
pinned: "1.55.2",
reason: "stale",
},
{
integration: "a2a-middleware",
dep: "@copilotkit/runtime",
pinned: "latest",
reason: "floating-tag",
},
],
"1.56.4",
);
expect(out).toContain("Found 2 stale pin(s)");
expect(out).toContain("[stale] adk: @copilotkit/react-core@1.55.2");
expect(out).toContain(
"[floating-tag] a2a-middleware: @copilotkit/runtime@latest",
);
expect(out).toContain("expected 1.56.4");
});
});
describe("live tree", () => {
it("examples/integrations/* @copilotkit pins match the monorepo release version", () => {
const repoRoot = path.resolve(__dirname, "..", "..");
const reactCorePkg = JSON.parse(
fs.readFileSync(
path.join(repoRoot, "packages", "react-core", "package.json"),
"utf8",
),
);
const expectedVersion = reactCorePkg.version;
const violations = validatePins({
expectedVersion,
integrationsDir: path.join(repoRoot, "examples", "integrations"),
});
if (violations.length > 0) {
console.error(formatViolations(violations, expectedVersion));
}
expect(violations).toEqual([]);
});
});
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
set -euo pipefail
# Archive script: update README with deprecation notice, then archive demo repos
# Usage: ./scripts/archive-demo-repos.sh [--group A|B|C|D|all] [--dry-run]
CLONE_DIR="$(mktemp -d)"
trap 'rm -rf "$CLONE_DIR"' EXIT
GROUP_FILTER="all"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--group) GROUP_FILTER="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
*) shift ;;
esac
done
# Track results for summary
ARCHIVED=0
FAILED=0
FAILURES=()
log_failure() { FAILURES+=("$1"); echo " FAILED: $1"; }
archive_repo() {
local repo="$1"
local target_path="$2"
echo "==> Archiving CopilotKit/$repo (-> $target_path)"
if [[ $DRY_RUN -eq 1 ]]; then
echo " DRY RUN: Would update README and archive CopilotKit/$repo"
((ARCHIVED++))
return 0
fi
# Step 1: Shallow clone
local clone_dest="$CLONE_DIR/$repo"
rm -rf "$clone_dest"
if ! git clone --depth 1 "https://github.com/CopilotKit/${repo}.git" "$clone_dest" 2>/dev/null; then
log_failure "$repo: Could not clone"
((FAILED++))
return 0
fi
# Step 2: Replace README with deprecation notice
cat > "$clone_dest/README.md" <<README_EOF
# This repository has been archived
This project has been consolidated into the [CopilotKit monorepo](https://github.com/CopilotKit/CopilotKit).
**New location:** [\`${target_path}\`](https://github.com/CopilotKit/CopilotKit/tree/main/${target_path})
Please open issues and pull requests in the [main CopilotKit repository](https://github.com/CopilotKit/CopilotKit).
README_EOF
# Step 3: Commit and push the updated README
if ! (cd "$clone_dest" && git add README.md && git commit -m "Point to monorepo location before archiving" --quiet && git push --quiet); then
log_failure "$repo: Could not push deprecation README"
((FAILED++))
rm -rf "$clone_dest"
return 0
fi
rm -rf "$clone_dest"
# Step 4: Archive the repo
if ! gh repo archive "CopilotKit/$repo" --yes 2>/dev/null; then
log_failure "$repo: Could not archive (README was updated)"
((FAILED++))
return 0
fi
((ARCHIVED++))
echo " OK: Archived CopilotKit/$repo"
}
# ============================================================
# MANIFEST: All 47 consolidated repos organized by group
# Format: archive_repo <github-repo-name> <target-path>
#
# NOT archived (not consolidated — experiments, dropped showcases, and starters):
# vnext_experimental_angular_demo, 1.50-demo, private_a2ui_demo,
# llamaindex-composio-hackathon-sample, vnext-with-pydantic,
# copilotkit-jupyter-notebook, deep-agent-cpk-experiments,
# crew-flow-ent-dojo, crew-flow-cpk-temp, ag2-feature-viewer,
# ag-ui-expo-playground, find-the-bug, cuddly-fortnight,
# crew_ai_enterprise_demo, agui-demo, demo-campaign-manager,
# demo-chat-sso, demo-crm, autotale-ai-web-ui,
# example-textarea, example-todos-app, coagents-starter-langgraph,
# coagents-starter-crewai-flows, llamaindex-hitl-guide-example,
# enterprise-runner-example, react-vite-built-in-agent
# ============================================================
archive_group_a() {
echo ""
echo "========== GROUP A: Demo Team Repos (22) =========="
echo ""
archive_repo with-langgraph-python examples/integrations/langgraph-python
archive_repo with-langgraph-js examples/integrations/langgraph-js
archive_repo with-langgraph-fastapi examples/integrations/langgraph-fastapi
archive_repo with-mastra examples/integrations/mastra
archive_repo with-crewai-flows examples/integrations/crewai-flows
archive_repo with-llamaindex examples/integrations/llamaindex
archive_repo with-pydantic-ai examples/integrations/pydantic-ai
archive_repo with-microsoft-agent-framework-python examples/integrations/ms-agent-framework-python
archive_repo with-microsoft-agent-framework-dotnet examples/integrations/ms-agent-framework-dotnet
archive_repo with-strands-python examples/integrations/strands-python
archive_repo with-mcp-apps examples/integrations/mcp-apps
archive_repo with-adk examples/integrations/adk
archive_repo with-agent-spec examples/integrations/agent-spec
archive_repo with-a2a-a2ui examples/integrations/a2a-a2ui
archive_repo demo-banking examples/showcases/banking
archive_repo demo-presentation examples/showcases/presentation
archive_repo deep-agents-demo examples/showcases/deep-agents
archive_repo deep-agents-job-search-assistant examples/showcases/deep-agents-job-search
archive_repo generative-ui examples/showcases/generative-ui
archive_repo generative-ui-playground examples/showcases/generative-ui-playground
archive_repo mcp-apps-demo examples/showcases/mcp-apps
archive_repo open-research-ANA examples/showcases/research-canvas
}
archive_group_b() {
echo ""
echo "========== GROUP B: Additional Repos (21) =========="
echo ""
# Integration starters
archive_repo with-agno examples/integrations/agno
archive_repo with-crewai-crews examples/integrations/crewai-crews
archive_repo with-a2a-middleware examples/integrations/a2a-middleware
# Canvas demos
archive_repo canvas-with-langgraph-python examples/canvas/langgraph-python
archive_repo canvas-with-llamaindex examples/canvas/llamaindex
archive_repo canvas-with-llamaindex-composio examples/canvas/llamaindex-composio
archive_repo canvas-with-pydantic-ai examples/canvas/pydantic-ai
archive_repo canvas-with-mastra examples/canvas/mastra
# Feature demos
archive_repo copilotkit-mcp-demo examples/showcases/mcp-demo
archive_repo strands-file-analyzer-demo examples/showcases/strands-file-analyzer
archive_repo microsoft-kanban-demo examples/showcases/microsoft-kanban
archive_repo multi-page-demo examples/showcases/multi-page
archive_repo orca-CopilotKit-demo examples/showcases/orca
# Showcases
archive_repo pydantic-ai-todos examples/showcases/pydantic-ai-todos
archive_repo scene-creator-copilot examples/showcases/scene-creator
archive_repo open-gemini-canvas examples/canvas/gemini
archive_repo adk-generative-dashboard examples/showcases/adk-dashboard
archive_repo mastra-pm-canvas examples/canvas/mastra-pm
archive_repo langgraph-js-support-agents examples/showcases/langgraph-js-support-agents
archive_repo open-multi-agent-canvas examples/showcases/multi-agent-canvas
archive_repo open-chatkit-studio examples/showcases/chatkit-studio
}
archive_group_c() {
echo ""
echo "========== GROUP C: Remaining Repos (2) =========="
echo ""
archive_repo enterprise-brex-demo examples/showcases/enterprise-brex
archive_repo a2a-travel examples/showcases/a2a-travel
}
archive_group_d() {
echo ""
echo "========== GROUP D: Markus Ecker Forks (2) =========="
echo ""
archive_repo demo-spreadsheet examples/showcases/spreadsheet
archive_repo demo-todo examples/showcases/todo
}
# ============================================================
# Main
# ============================================================
echo "Archive script starting..."
echo "Temp dir: $CLONE_DIR"
echo "Group filter: $GROUP_FILTER"
echo "Dry run: $DRY_RUN"
echo ""
case "$GROUP_FILTER" in
A|a) archive_group_a ;;
B|b) archive_group_b ;;
C|c) archive_group_c ;;
D|d) archive_group_d ;;
all)
archive_group_a
archive_group_b
archive_group_c
archive_group_d
;;
*) echo "Unknown group: $GROUP_FILTER"; exit 1 ;;
esac
echo ""
echo "========== SUMMARY =========="
echo "Archived: $ARCHIVED"
echo "Failed: $FAILED"
if [[ ${#FAILURES[@]} -gt 0 ]]; then
echo ""
echo "FAILURES (${#FAILURES[@]}):"
for f in "${FAILURES[@]}"; do
echo " - $f"
done
exit 1
fi
+141
View File
@@ -0,0 +1,141 @@
#! /bin/bash
set -e # Exit immediately if a command exits with a non-zero status
# Save the current directory
root_dir=$(pwd)
# If the first argument is --help, list all possible examples
if [ "$1" == "--help" ]; then
echo "Usage: $0 [example_directory] [backend]"
echo " - example_directory: The name of the example directory (default: coagents-research-canvas)"
echo " - backend: The backend to use (fastapi or langgraph-platform, default: fastapi)"
echo ""
echo "NOTE: Make sure to have GNU parallel and langgraph CLI installed."
echo ""
echo "Available example directories:"
for dir in $(ls -d "$root_dir/../examples/"*/); do
# Skip the 'e2e' directory
if [ "$(basename "$dir")" == "e2e" ]; then
continue
fi
echo " - $(basename "$dir")"
done
exit 0
fi
# Check if GNU parallel is installed
if ! command -v parallel &> /dev/null; then
echo "Error:GNU parallel is not installed. Please install to proceed. (brew install parallel)"
exit 1
fi
# The first argument is the example directory name, defaulting to "coagents-research-canvas" if not provided
example_dir="${1:-coagents-research-canvas}"
if [[ "$example_dir" == "coagents-starter" || \
"$example_dir" == "langgraph-tutorial-customer-support" || \
"$example_dir" == "langgraph-tutorial-quickstart" || \
"$example_dir" == "coagents-starter-crewai-flows" ]]; then
agent_dir="agent-py"
else
agent_dir="agent"
fi
# Check if the example directory exists
if [ ! -d "$root_dir/../examples/$example_dir" ]; then
echo "Example directory $example_dir does not exist"
exit 1
fi
backend="${2:-fastapi}"
# Check if the backend is valid
if [[ "$backend" != "fastapi" && "$backend" != "langgraph-platform" ]]; then
echo "Invalid backend: $backend. Must be 'fastapi' or 'langgraph-platform'."
exit 1
fi
# Ensure "langgraph" is installed if the backend is "langgraph-platform"
if [[ "$backend" == "langgraph-platform" ]]; then
if ! command -v langgraph &> /dev/null; then
echo "Error: 'langgraph' is not installed. Please install to proceed. (brew install langgraph-cli)"
exit 1
fi
fi
on_exit() {
cd "$root_dir/../examples/$example_dir/$agent_dir"
# Only revert and echo if pyproject.toml or poetry.lock have changes
if ! git diff --quiet -- pyproject.toml poetry.lock; then
echo "------------------------------------------------------"
echo "Reverting changes to pyproject.toml and poetry.lock..."
echo "------------------------------------------------------"
git checkout -- pyproject.toml poetry.lock
fi
cd "$root_dir"
}
# Ensure cleanup/revert is called when the script exits or on error
trap on_exit EXIT ERR
if [[ "$backend" == "langgraph-platform" ]]; then
echo "------------------------------------------------------------------"
echo "URL: http://localhost:3000/?lgcDeploymentUrl=http://localhost:2024"
echo "------------------------------------------------------------------"
fi
if [[ "$backend" == "fastapi" ]]; then
echo "--------------------------"
echo "URL: http://localhost:3000"
echo "--------------------------"
fi
echo "Linking packages globally..."
npx nx run-many -t link:global
echo "Setting up the JS environment..."
cd "$root_dir/../examples/$example_dir/ui"
rm -rf .next
pnpm i
pnpm link --global @copilotkit/react-ui @copilotkit/react-core @copilotkit/runtime-client-gql \
@copilotkit/shared @copilotkit/runtime @copilotkit/sdk-js
echo "Setting up the Python environment..."
cd "$root_dir/../examples/$example_dir/$agent_dir"
poetry lock
poetry install
# Add sdk-python as an editable package only if the backend is fastapi
if [[ "$backend" == "fastapi" ]]; then
poetry add --editable ../../../sdk-python
fi
if [[ "$backend" == "langgraph-platform" ]]; then
python -m pip install -e .
fi
# Define color prompts
copilotkit_prompt="\x1b[1;32m[CopilotKit]\x1b[0m"
agent_prompt="\x1b[1;31m[Agent ]\x1b[0m"
frontend_prompt="\x1b[1;34m[Frontend ]\x1b[0m"
# Determine the command to run based on the backend
if [[ "$backend" == "fastapi" ]]; then
agent_command="poetry run demo"
else
agent_command="langgraph dev --no-browser"
fi
echo "Running the app..."
parallel --ungroup ::: \
"cd $root_dir && exec > >(sed 's/^/$copilotkit_prompt /') 2>&1 && npx nx run-many -t dev" \
"cd $root_dir/../examples/$example_dir/$agent_dir && exec > >(sed 's/^/$agent_prompt /') 2>&1 && $agent_command" \
"cd $root_dir/../examples/$example_dir/ui && exec > >(sed 's/^/$frontend_prompt /') 2>&1 && pnpm dev"
+229
View File
@@ -0,0 +1,229 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { parseMeta, extractFromMdx, writeExtractedBlocks } from "../extract";
// ---------------------------------------------------------------------------
// parseMeta
// ---------------------------------------------------------------------------
describe("parseMeta", () => {
it("extracts key=value pairs from meta string", () => {
const meta = 'title="main.py" doctest="server"';
expect(parseMeta(meta)).toEqual({ title: "main.py", doctest: "server" });
});
it("handles single quotes", () => {
const meta = "title='server.ts' doctest='component'";
expect(parseMeta(meta)).toEqual({
title: "server.ts",
doctest: "component",
});
});
it("returns empty object for empty meta", () => {
expect(parseMeta("")).toEqual({});
});
});
// ---------------------------------------------------------------------------
// extractFromMdx
// ---------------------------------------------------------------------------
describe("extractFromMdx", () => {
it("extracts a doctest='server' block from simple MDX", () => {
const mdx = `
# My Page
Some text.
\`\`\`python title="main.py" doctest="server"
import os
print("hello")
\`\`\`
`;
const blocks = extractFromMdx(
mdx,
"/showcase/shell-docs/src/content/test.mdx",
);
expect(blocks).toHaveLength(1);
expect(blocks[0].lang).toBe("python");
expect(blocks[0].title).toBe("main.py");
expect(blocks[0].doctest).toBe("server");
expect(blocks[0].code).toContain('print("hello")');
});
it("extracts multiple blocks from one file", () => {
const mdx = `
\`\`\`python title="a.py" doctest="script"
print("a")
\`\`\`
\`\`\`typescript title="b.ts" doctest="component"
const x = 1;
\`\`\`
`;
const blocks = extractFromMdx(
mdx,
"/showcase/shell-docs/src/content/multi.mdx",
);
expect(blocks).toHaveLength(2);
expect(blocks[0].doctest).toBe("script");
expect(blocks[1].doctest).toBe("component");
});
it("ignores blocks without doctest attribute", () => {
const mdx = `
\`\`\`python title="main.py"
print("no doctest")
\`\`\`
\`\`\`bash
echo "also no doctest"
\`\`\`
\`\`\`python title="tested.py" doctest="script"
print("has doctest")
\`\`\`
`;
const blocks = extractFromMdx(
mdx,
"/showcase/shell-docs/src/content/mixed.mdx",
);
expect(blocks).toHaveLength(1);
expect(blocks[0].title).toBe("tested.py");
});
it("handles indented blocks inside JSX (Tab component)", () => {
const mdx = `
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
<Tabs items={["Python", "TypeScript"]}>
<Tab value="Python">
\`\`\`python title="main.py" doctest="server"
import fastapi
app = fastapi.FastAPI()
\`\`\`
</Tab>
</Tabs>
`;
const blocks = extractFromMdx(
mdx,
"/showcase/shell-docs/src/content/tabbed.mdx",
);
expect(blocks).toHaveLength(1);
expect(blocks[0].lang).toBe("python");
expect(blocks[0].doctest).toBe("server");
expect(blocks[0].code).toContain("fastapi");
});
});
// ---------------------------------------------------------------------------
// writeExtractedBlocks
// ---------------------------------------------------------------------------
describe("writeExtractedBlocks", () => {
let tmpDir: string;
let outputDir: string;
let docsDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "doctest-"));
outputDir = path.join(tmpDir, "output");
docsDir = path.join(tmpDir, "docs");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(docsDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true });
});
it("groups blocks with the same title into one file", () => {
const blocks = [
{
lang: "python",
title: "main.py",
doctest: "server",
code: "# part 1",
line: 10,
sourceFile: path.join(docsDir, "guide.mdx"),
},
{
lang: "python",
title: "main.py",
doctest: "server",
code: "# part 2",
line: 30,
sourceFile: path.join(docsDir, "guide.mdx"),
},
];
const manifest = writeExtractedBlocks(blocks, outputDir, docsDir);
expect(manifest).toHaveLength(1);
expect(manifest[0].file).toBe("guide/main.py");
const written = fs.readFileSync(
path.join(outputDir, "guide", "main.py"),
"utf-8",
);
expect(written).toContain("# part 1");
expect(written).toContain("# part 2");
});
it("writes correct manifest.json structure", () => {
const blocks = [
{
lang: "python",
title: "server.py",
doctest: "server",
code: "print('hello')",
line: 5,
sourceFile: path.join(docsDir, "integrations", "test.mdx"),
},
];
// Create the nested dir so the relative path works
fs.mkdirSync(path.join(docsDir, "integrations"), { recursive: true });
const manifest = writeExtractedBlocks(blocks, outputDir, docsDir);
expect(manifest).toHaveLength(1);
expect(manifest[0].lang).toBe("python");
expect(manifest[0].category).toBe("server");
expect(manifest[0].source).toContain(":5");
expect(manifest[0].id).toContain("integrations-test");
});
it("copies doctest.json sidecar when present", () => {
const sidecar = { python: { deps: ["flask"] } };
fs.writeFileSync(
path.join(docsDir, "doctest.json"),
JSON.stringify(sidecar),
);
const blocks = [
{
lang: "python",
title: "app.py",
doctest: "server",
code: "from flask import Flask",
line: 1,
sourceFile: path.join(docsDir, "page.mdx"),
},
];
writeExtractedBlocks(blocks, outputDir, docsDir);
const copied = JSON.parse(
fs.readFileSync(path.join(outputDir, "page", "doctest.json"), "utf-8"),
);
expect(copied.python.deps).toContain("flask");
});
});
+336
View File
@@ -0,0 +1,336 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkMdx from "remark-mdx";
import { visit } from "unist-util-visit";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CodeBlock {
lang: string;
title: string;
doctest: string;
code: string;
line: number;
sourceFile: string;
}
interface ManifestEntry {
id: string;
file: string;
lang: string;
category: string;
source: string;
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const DOCS_DIR = path.resolve(
__dirname,
"../../showcase/shell-docs/src/content",
);
const OUTPUT_DIR = path.resolve(__dirname, "../../.doctest-output");
// ---------------------------------------------------------------------------
// AST Extraction
// ---------------------------------------------------------------------------
const parser = unified().use(remarkParse).use(remarkMdx);
/**
* Strip common leading whitespace from all lines of a code block.
* Handles indented code blocks inside JSX (Tabs, If, etc.) that
* preserve the JSX indentation in the extracted code.
*/
function stripCommonIndent(code: string): string {
const lines = code.split("\n");
const nonEmptyLines = lines.filter((l) => l.trim().length > 0);
if (nonEmptyLines.length === 0) return code;
const minIndent = Math.min(
...nonEmptyLines.map((l) => l.match(/^(\s*)/)![1].length),
);
if (minIndent === 0) return code;
return lines.map((l) => l.slice(minIndent)).join("\n");
}
/**
* Parse the meta string from a code fence to extract key-value attributes.
*
* Handles formats like:
* python title="main.py" doctest="server"
* typescript title="server.ts" doctest="component"
*/
export function parseMeta(meta: string): Record<string, string> {
const attrs: Record<string, string> = {};
// Match key="value" or key='value'
const regex = /(\w+)=["']([^"']+)["']/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(meta)) !== null) {
attrs[match[1]] = match[2];
}
return attrs;
}
/**
* Extract all code blocks with a doctest attribute from an MDX file.
*/
export function extractFromMdx(
content: string,
sourceFile: string,
): CodeBlock[] {
const blocks: CodeBlock[] = [];
let tree: ReturnType<typeof parser.parse>;
try {
tree = parser.parse(content);
} catch {
// Some MDX files have JSX constructs that trip the parser.
// Fall back to a regex-based extraction for resilience.
return extractFromMdxFallback(content, sourceFile);
}
visit(tree, "code", (node: any) => {
const lang = node.lang || "";
const meta = node.meta || "";
const attrs = parseMeta(meta);
if (!attrs.doctest) return;
const line =
node.position && node.position.start ? node.position.start.line : 0;
blocks.push({
lang,
title: attrs.title || `snippet.${langToExt(lang)}`,
doctest: attrs.doctest,
code: stripCommonIndent(node.value),
line,
sourceFile,
});
});
return blocks;
}
/**
* Regex-based fallback for MDX files that trip the remark-mdx parser.
* Only extracts code blocks with doctest attributes — less precise on
* position, but sufficient for our purposes.
*/
function extractFromMdxFallback(
content: string,
sourceFile: string,
): CodeBlock[] {
const blocks: CodeBlock[] = [];
const lines = content.split("\n");
let inBlock = false;
let blockLang = "";
let blockMeta = "";
let blockLines: string[] = [];
let blockStart = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trimStart();
if (!inBlock && /^```(\w+)(.*)$/.test(trimmed)) {
const match = trimmed.match(/^```(\w+)(.*)$/);
if (match) {
blockLang = match[1];
blockMeta = match[2];
blockLines = [];
blockStart = i + 1;
inBlock = true;
}
} else if (inBlock && /^```\s*$/.test(trimmed)) {
const attrs = parseMeta(blockMeta);
if (attrs.doctest) {
blocks.push({
lang: blockLang,
title: attrs.title || `snippet.${langToExt(blockLang)}`,
doctest: attrs.doctest,
code: stripCommonIndent(blockLines.join("\n")),
line: blockStart,
sourceFile,
});
}
inBlock = false;
} else if (inBlock) {
blockLines.push(lines[i]);
}
}
return blocks;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function langToExt(lang: string): string {
switch (lang) {
case "python":
return "py";
case "typescript":
case "tsx":
return "ts";
case "javascript":
case "jsx":
return "js";
default:
return lang || "txt";
}
}
function slugify(filePath: string): string {
return filePath
.replace(/\.mdx$/, "")
.replace(/[/\\]/g, "-")
.replace(/[^a-zA-Z0-9-]/g, "");
}
/**
* Walk a directory tree and return all .mdx files.
*/
function findMdxFiles(dir: string): string[] {
const results: string[] = [];
function walk(current: string) {
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name.startsWith(".") || entry.name === "node_modules")
continue;
walk(full);
} else if (entry.name.endsWith(".mdx")) {
results.push(full);
}
}
}
walk(dir);
return results.sort();
}
// ---------------------------------------------------------------------------
// Output generation
// ---------------------------------------------------------------------------
/**
* Group extracted blocks by page slug and title, then write to output dir.
* Blocks sharing the same title within a page are concatenated into one file.
*/
export function writeExtractedBlocks(
blocks: CodeBlock[],
outputDir: string,
docsDir: string,
): ManifestEntry[] {
const manifest: ManifestEntry[] = [];
// Group by (page slug, title)
const grouped = new Map<string, CodeBlock[]>();
for (const block of blocks) {
const rel = path.relative(docsDir, block.sourceFile);
const slug = slugify(rel);
const key = `${slug}/${block.title}`;
const existing = grouped.get(key) || [];
existing.push(block);
grouped.set(key, existing);
}
for (const [key, groupBlocks] of grouped) {
const slug = key.split("/")[0];
const title = groupBlocks[0].title;
const dir = path.join(outputDir, slug);
fs.mkdirSync(dir, { recursive: true });
// Concatenate code from all blocks sharing this title
const code = groupBlocks.map((b) => b.code).join("\n\n");
const filePath = path.join(dir, title);
fs.writeFileSync(filePath, code, "utf-8");
// Copy doctest.json sidecar if it exists
const sidecarPath = path.join(
path.dirname(groupBlocks[0].sourceFile),
"doctest.json",
);
const destSidecar = path.join(dir, "doctest.json");
if (fs.existsSync(sidecarPath) && !fs.existsSync(destSidecar)) {
fs.copyFileSync(sidecarPath, destSidecar);
}
const firstBlock = groupBlocks[0];
const relSource = path.relative(
path.resolve(docsDir, ".."),
firstBlock.sourceFile,
);
const id = `${slug}-${title.replace(/[^a-zA-Z0-9]/g, "-")}`;
manifest.push({
id,
file: `${slug}/${title}`,
lang: firstBlock.lang,
category: firstBlock.doctest,
source: `${relSource}:${firstBlock.line}`,
});
}
return manifest;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export function extract(
docsDir: string = DOCS_DIR,
outputDir: string = OUTPUT_DIR,
): ManifestEntry[] {
// Clean output dir
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true });
}
fs.mkdirSync(outputDir, { recursive: true });
const files = findMdxFiles(docsDir);
const allBlocks: CodeBlock[] = [];
for (const file of files) {
const content = fs.readFileSync(file, "utf-8");
const blocks = extractFromMdx(content, file);
allBlocks.push(...blocks);
}
const manifest = writeExtractedBlocks(allBlocks, outputDir, docsDir);
// Write manifest
const manifestPath = path.join(outputDir, "manifest.json");
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
console.log(`Extracted ${manifest.length} doctest snippet(s):`);
for (const entry of manifest) {
console.log(` ${entry.id} [${entry.category}] ${entry.source}`);
}
return manifest;
}
// ---------------------------------------------------------------------------
// CLI entry point
// ---------------------------------------------------------------------------
const isDirectRun = typeof require !== "undefined" && require.main === module;
if (isDirectRun) {
extract();
}
+10
View File
@@ -0,0 +1,10 @@
{
"fixtures": [
{
"match": {},
"response": {
"content": "Hello! I'm an AI assistant."
}
}
]
}
+486
View File
@@ -0,0 +1,486 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { execSync, spawn } from "node:child_process";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ManifestEntry {
id: string;
file: string;
lang: string;
category: string;
source: string;
}
interface DoctestConfig {
python?: { deps: string[] };
typescript?: { deps: string[] };
node?: { deps: string[] };
}
interface Result {
id: string;
category: string;
status: "pass" | "fail";
error?: string;
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const OUTPUT_DIR = path.resolve(__dirname, "../../.doctest-output");
const MANIFEST_PATH = path.join(OUTPUT_DIR, "manifest.json");
const DEFAULT_ENV: Record<string, string> = {
OPENAI_API_KEY: "test-key",
OPENAI_BASE_URL: "http://localhost:4010",
};
const SERVER_TIMEOUT_MS = 30_000;
const SERVER_POLL_MS = 500;
const SCRIPT_TIMEOUT_MS = 30_000;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function validateDepName(dep: string): string {
if (!/^[@\w][\w./-]*(?:@[\w.^~>=<*-]+)?$/.test(dep)) {
throw new Error(`Invalid dependency name: ${dep}`);
}
return dep;
}
function loadDoctestConfig(snippetDir: string): DoctestConfig {
const configPath = path.join(snippetDir, "doctest.json");
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
}
return {};
}
function mergeEnv(extra?: Record<string, string>): Record<string, string> {
return { ...process.env, ...DEFAULT_ENV, ...extra } as Record<string, string>;
}
async function waitForPort(
port: number,
timeoutMs: number,
pollMs: number,
shouldContinue: () => boolean = () => true,
): Promise<boolean> {
const start = Date.now();
while (shouldContinue() && Date.now() - start < timeoutMs) {
try {
const resp = await fetch(`http://localhost:${port}/`).catch(() => null);
if (resp) return true;
} catch {
// Server not ready yet
}
await new Promise((r) => setTimeout(r, pollMs));
}
return false;
}
function collectProcessOutput(proc: ReturnType<typeof spawn>): {
isRunning: () => boolean;
output: () => string;
} {
let exited = false;
let output = "";
proc.stdout?.on("data", (chunk) => {
output += chunk.toString();
});
proc.stderr?.on("data", (chunk) => {
output += chunk.toString();
});
proc.on("exit", (code, signal) => {
exited = true;
output += `\n[process exited with ${signal ? `signal ${signal}` : `code ${code}`}]`;
});
return {
isRunning: () => !exited,
output: () => output.trim(),
};
}
function serverStartError(
port: number,
proc: ReturnType<typeof collectProcessOutput>,
): string {
const output = proc.output();
if (output) {
return `Server did not bind to port ${port}. Process output:\n${output}`;
}
return `Server did not bind to port ${port} within ${SERVER_TIMEOUT_MS}ms`;
}
function detectPort(code: string): number {
// Look for port=NNNN or PORT=NNNN or --port NNNN
const match = code.match(/\bport[=\s:]+(\d{4,5})/i);
return match ? parseInt(match[1], 10) : 8000;
}
// ---------------------------------------------------------------------------
// Runners
// ---------------------------------------------------------------------------
async function runPythonServer(
snippetDir: string,
entryFile: string,
config: DoctestConfig,
): Promise<Result> {
const id = path.basename(snippetDir);
const venvDir = path.join(snippetDir, ".venv");
try {
// Create virtualenv
execSync(`python3 -m venv ${venvDir}`, { cwd: snippetDir, stdio: "pipe" });
const pip = path.join(venvDir, "bin", "pip");
const python = path.join(venvDir, "bin", "python");
// Install deps
const deps = config.python?.deps || [];
if (deps.length > 0) {
const safeDeps = deps.map(validateDepName);
execSync(`${pip} install ${safeDeps.join(" ")}`, {
cwd: snippetDir,
stdio: "pipe",
timeout: 120_000,
});
}
const code = fs.readFileSync(path.join(snippetDir, entryFile), "utf-8");
const port = detectPort(code);
// Start server
const proc = spawn(python, [entryFile], {
cwd: snippetDir,
env: mergeEnv(),
stdio: "pipe",
});
const serverProcess = collectProcessOutput(proc);
try {
const ready = await waitForPort(
port,
SERVER_TIMEOUT_MS,
SERVER_POLL_MS,
serverProcess.isRunning,
);
if (!ready) {
return {
id,
category: "server",
status: "fail",
error: serverStartError(port, serverProcess),
};
}
return { id, category: "server", status: "pass" };
} finally {
try {
proc.kill("SIGTERM");
} catch {}
}
} catch (e: any) {
return {
id,
category: "server",
status: "fail",
error: e.message || String(e),
};
}
}
async function runTypeScriptServer(
snippetDir: string,
entryFile: string,
config: DoctestConfig,
): Promise<Result> {
const id = path.basename(snippetDir);
try {
// Init and install deps
execSync("npm init -y", { cwd: snippetDir, stdio: "pipe" });
const deps = config.typescript?.deps || config.node?.deps || [];
if (deps.length > 0) {
const safeDeps = deps.map(validateDepName);
execSync(`npm install ${safeDeps.join(" ")}`, {
cwd: snippetDir,
stdio: "pipe",
timeout: 120_000,
});
}
const code = fs.readFileSync(path.join(snippetDir, entryFile), "utf-8");
const port = detectPort(code);
// Determine runner
const runner = entryFile.endsWith(".ts") ? "npx tsx" : "node";
const proc = spawn(
runner.split(" ")[0],
[...runner.split(" ").slice(1), entryFile],
{
cwd: snippetDir,
env: mergeEnv(),
stdio: "pipe",
},
);
const serverProcess = collectProcessOutput(proc);
try {
const ready = await waitForPort(
port,
SERVER_TIMEOUT_MS,
SERVER_POLL_MS,
serverProcess.isRunning,
);
if (!ready) {
return {
id,
category: "server",
status: "fail",
error: serverStartError(port, serverProcess),
};
}
return { id, category: "server", status: "pass" };
} finally {
try {
proc.kill("SIGTERM");
} catch {}
}
} catch (e: any) {
return {
id,
category: "server",
status: "fail",
error: e.message || String(e),
};
}
}
async function runScript(
snippetDir: string,
entryFile: string,
lang: string,
config: DoctestConfig,
): Promise<Result> {
const id = path.basename(snippetDir);
try {
if (lang === "python") {
const venvDir = path.join(snippetDir, ".venv");
execSync(`python3 -m venv ${venvDir}`, {
cwd: snippetDir,
stdio: "pipe",
});
const pip = path.join(venvDir, "bin", "pip");
const python = path.join(venvDir, "bin", "python");
const deps = config.python?.deps || [];
if (deps.length > 0) {
const safeDeps = deps.map(validateDepName);
execSync(`${pip} install ${safeDeps.join(" ")}`, {
cwd: snippetDir,
stdio: "pipe",
timeout: 120_000,
});
}
execSync(`${python} ${entryFile}`, {
cwd: snippetDir,
env: mergeEnv(),
stdio: "pipe",
timeout: SCRIPT_TIMEOUT_MS,
});
} else {
execSync("npm init -y", { cwd: snippetDir, stdio: "pipe" });
const deps = config.typescript?.deps || config.node?.deps || [];
if (deps.length > 0) {
const safeDeps = deps.map(validateDepName);
execSync(`npm install ${safeDeps.join(" ")}`, {
cwd: snippetDir,
stdio: "pipe",
timeout: 120_000,
});
}
const runner = entryFile.endsWith(".ts") ? "npx tsx" : "node";
execSync(`${runner} ${entryFile}`, {
cwd: snippetDir,
env: mergeEnv(),
stdio: "pipe",
timeout: SCRIPT_TIMEOUT_MS,
});
}
return { id, category: "script", status: "pass" };
} catch (e: any) {
return {
id,
category: "script",
status: "fail",
error: e.message || String(e),
};
}
}
async function runComponent(
snippetDir: string,
entryFile: string,
config: DoctestConfig,
): Promise<Result> {
const id = path.basename(snippetDir);
try {
execSync("npm init -y", { cwd: snippetDir, stdio: "pipe" });
const deps = config.typescript?.deps || [];
const baseDeps = ["typescript", "@types/react", "@types/node"];
const allDeps = [...new Set([...baseDeps, ...deps])];
const safeAllDeps = allDeps.map(validateDepName);
execSync(`npm install ${safeAllDeps.join(" ")}`, {
cwd: snippetDir,
stdio: "pipe",
timeout: 120_000,
});
// Write minimal tsconfig if none exists
const tsconfigPath = path.join(snippetDir, "tsconfig.json");
if (!fs.existsSync(tsconfigPath)) {
fs.writeFileSync(
tsconfigPath,
JSON.stringify(
{
compilerOptions: {
target: "ES2020",
module: "ESNext",
moduleResolution: "bundler",
jsx: "react-jsx",
strict: true,
noEmit: true,
esModuleInterop: true,
skipLibCheck: true,
},
include: [entryFile],
},
null,
2,
),
"utf-8",
);
}
execSync("npx tsc --noEmit", {
cwd: snippetDir,
stdio: "pipe",
timeout: SCRIPT_TIMEOUT_MS,
});
return { id, category: "component", status: "pass" };
} catch (e: any) {
return {
id,
category: "component",
status: "fail",
error: e.message || String(e),
};
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
if (!fs.existsSync(MANIFEST_PATH)) {
console.error(
`Manifest not found at ${MANIFEST_PATH}. Run extract.ts first.`,
);
process.exit(1);
}
const manifest: ManifestEntry[] = JSON.parse(
fs.readFileSync(MANIFEST_PATH, "utf-8"),
);
if (manifest.length === 0) {
console.log("No doctest snippets found in manifest.");
process.exit(0);
}
console.log(`Running ${manifest.length} doctest snippet(s)...\n`);
const results: Result[] = [];
for (const entry of manifest) {
const snippetDir = path.join(OUTPUT_DIR, path.dirname(entry.file));
const entryFile = path.basename(entry.file);
const config = loadDoctestConfig(snippetDir);
console.log(` Running: ${entry.id} [${entry.category}/${entry.lang}]`);
let result: Result;
if (entry.category === "server") {
if (entry.lang === "python") {
result = await runPythonServer(snippetDir, entryFile, config);
} else {
result = await runTypeScriptServer(snippetDir, entryFile, config);
}
} else if (entry.category === "script") {
result = await runScript(snippetDir, entryFile, entry.lang, config);
} else if (entry.category === "component") {
result = await runComponent(snippetDir, entryFile, config);
} else {
result = {
id: entry.id,
category: entry.category,
status: "fail",
error: `Unknown category: ${entry.category}`,
};
}
results.push(result);
const icon = result.status === "pass" ? "PASS" : "FAIL";
console.log(
` ${icon}: ${entry.id}${result.error ? `${result.error}` : ""}\n`,
);
}
// Summary
const passed = results.filter((r) => r.status === "pass").length;
const failed = results.filter((r) => r.status === "fail").length;
console.log("─".repeat(60));
console.log(
`Results: ${passed} passed, ${failed} failed, ${results.length} total`,
);
console.log("─".repeat(60));
if (failed > 0) {
console.log("\nFailed snippets:");
for (const r of results.filter((r) => r.status === "fail")) {
console.log(` ${r.id}: ${r.error}`);
}
process.exit(1);
}
}
main().catch((e) => {
console.error("Unexpected error:", e);
process.exit(1);
});
+33
View File
@@ -0,0 +1,33 @@
import path from "path";
import { REFERENCE_DOCS } from "./lib/files";
import { ReferenceDoc } from "./lib/reference-doc";
// Resolve all source/destination paths relative to the repo root so the
// pipeline behaves the same regardless of where it's invoked from.
const repoRoot = path.resolve(__dirname, "..", "..");
process.chdir(repoRoot);
// allSettled so one missing source (e.g. an SDK file that was renamed
// upstream) doesn't abort the entire pipeline — we still want every other
// reference page to land in shell-docs.
Promise.allSettled(
REFERENCE_DOCS.map(async (referenceDoc) => {
const doc = new ReferenceDoc(referenceDoc);
await doc.generate();
}),
).then((results) => {
const failures = results
.map((r, i) => ({ r, i }))
.filter(({ r }) => r.status === "rejected");
for (const { r, i } of failures) {
const reason = (r as PromiseRejectedResult).reason;
console.error(
`[gen] Failed: ${REFERENCE_DOCS[i].destinationPath}: ${
reason instanceof Error ? reason.message : String(reason)
}`,
);
}
console.log(
`All reference docs processed (${results.length - failures.length}/${results.length} succeeded)`,
);
});
+114
View File
@@ -0,0 +1,114 @@
import * as ts from "typescript";
export class Comments {
static getCleanedCommentsForNode(
node: ts.Node,
sourceFile: ts.SourceFile,
): string {
const fullText = sourceFile.getFullText();
const commentRanges = ts.getLeadingCommentRanges(
fullText,
node.getFullStart(),
);
if (!commentRanges) return "";
return commentRanges
.map((comment) => {
let commentText = fullText.substring(comment.pos, comment.end);
commentText = Comments.removeCommentSyntax(commentText);
// for now, remove @default annotations
commentText = commentText
.split("\n")
.filter((line) => !line.includes("@default"))
.join("\n");
return commentText;
})
.join("\n")
.trim();
}
static getDefaultValueForNode(
node: ts.Node,
sourceFile: ts.SourceFile,
): string | undefined {
const fullText = sourceFile.getFullText();
const commentRanges = ts.getLeadingCommentRanges(
fullText,
node.getFullStart(),
);
if (!commentRanges) return "";
let defaultValue: string | undefined = undefined;
for (const comment of commentRanges) {
let commentText = fullText.substring(comment.pos, comment.end);
commentText = Comments.removeCommentSyntax(commentText);
for (const line of commentText.split("\n")) {
if (line.includes("@default")) {
defaultValue = line.split("@default")[1].trim();
break;
}
}
if (defaultValue !== undefined) break;
}
return defaultValue;
}
static removeCommentSyntax(commentText: string): string {
return commentText
.replace(/\/\*\*|\*\/|\*|\/\* ?/gm, "")
.replace(/^ /gm, "")
.trim();
}
static getFirstCommentBlock(sourceFile: ts.SourceFile): string | null {
for (const statement of sourceFile.statements) {
const comments = Comments.getCleanedCommentsForNode(
statement,
sourceFile,
);
if (comments) return comments;
}
return null;
}
static getTsDocCommentsForFunction(node: ts.Node, sourceFile: ts.SourceFile) {
const params: Record<string, string> = {};
const trivia =
ts.getLeadingCommentRanges(sourceFile.text, node.getFullStart()) || [];
let comment = "";
for (const range of trivia) {
const commentText = Comments.removeCommentSyntax(
sourceFile.text.substring(range.pos, range.end),
);
const lines = commentText.split("\n").map((line) => line.trim());
if (lines.length && !lines[0].startsWith("@param")) {
comment = lines[0];
}
lines.forEach((line) => {
if (line.startsWith("@param")) {
const parts = line.split(/\s+/);
if (parts.length >= 3) {
const paramName = parts[1];
const description = parts.slice(2).join(" ");
params[paramName] = description.trim();
}
}
});
}
return { comment, params };
}
}
+243
View File
@@ -0,0 +1,243 @@
import type { ReferenceDocConfiguration } from "./reference-doc";
export const REFERENCE_DOCS: ReferenceDocConfiguration[] = [
/* Runtime */
{
sourcePath:
"packages/runtime/src/service-adapters/google/google-genai-adapter.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/classes/llm-adapters/GoogleGenerativeAIAdapter.mdx",
className: "GoogleGenerativeAIAdapter",
description:
"Copilot Runtime adapter for Google Generative AI (e.g. Gemini).",
},
{
sourcePath: "packages/runtime/src/service-adapters/groq/groq-adapter.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/classes/llm-adapters/GroqAdapter.mdx",
className: "GroqAdapter",
description: "Copilot Runtime adapter for Groq.",
},
{
sourcePath:
"packages/runtime/src/service-adapters/langchain/langchain-adapter.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/classes/llm-adapters/LangChainAdapter.mdx",
className: "LangChainAdapter",
description: "Copilot Runtime adapter for LangChain.",
},
{
sourcePath:
"packages/runtime/src/service-adapters/openai/openai-adapter.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/classes/llm-adapters/OpenAIAdapter.mdx",
className: "OpenAIAdapter",
description: "Copilot Runtime adapter for OpenAI.",
},
{
sourcePath:
"packages/runtime/src/service-adapters/openai/openai-assistant-adapter.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/classes/llm-adapters/OpenAIAssistantAdapter.mdx",
className: "OpenAIAssistantAdapter",
description: "Copilot Runtime adapter for OpenAI Assistant API.",
},
{
sourcePath:
"packages/runtime/src/service-adapters/anthropic/anthropic-adapter.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/classes/llm-adapters/AnthropicAdapter.mdx",
className: "AnthropicAdapter",
description: "Copilot Runtime adapter for Anthropic.",
},
/* Classes */
{
sourcePath: "packages/react-core/src/lib/copilot-task.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/classes/CopilotTask.mdx",
className: "CopilotTask",
description:
"CopilotTask is used to execute one-off tasks, for example on button click.",
},
{
sourcePath: "packages/runtime/src/lib/runtime/copilot-runtime.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/classes/CopilotRuntime.mdx",
className: "CopilotRuntime",
description:
"Copilot Runtime is the back-end component of CopilotKit, enabling interaction with LLMs.",
},
/* Components */
{
sourcePath: "packages/react-ui/src/components/chat/Chat.tsx",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/components/chat/CopilotChat.mdx",
component: "CopilotChat",
description:
"The CopilotChat component, providing a chat interface for interacting with your copilot.",
},
{
sourcePath:
"packages/react-core/src/components/copilot-provider/copilotkit.tsx",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/components/CopilotKit.mdx",
component: "CopilotKit",
description:
"The CopilotKit provider component, wrapping your application.",
},
{
sourcePath: "packages/react-ui/src/components/chat/Popup.tsx",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/components/chat/CopilotPopup.mdx",
component: "CopilotPopup",
description:
"The CopilotPopup component, providing a popup interface for interacting with your copilot.",
},
{
sourcePath: "packages/react-ui/src/components/chat/Sidebar.tsx",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/components/chat/CopilotSidebar.mdx",
component: "CopilotSidebar",
description:
"The CopilotSidebar component, providing a sidebar interface for interacting with your copilot.",
},
{
sourcePath:
"packages/react-textarea/src/components/copilot-textarea/copilot-textarea.tsx",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/components/CopilotTextarea.mdx",
component: "CopilotTextarea",
description:
"An AI-powered textarea component for your application, which serves as a drop-in replacement for any textarea.",
},
/* Hooks */
{
sourcePath: "packages/react-core/src/hooks/use-copilot-chat.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/hooks/useCopilotChat.mdx",
hook: "useCopilotChat",
},
{
sourcePath: "packages/react-core/src/hooks/use-copilot-chat-headless_c.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/hooks/useCopilotChatHeadless_c.mdx",
hook: "useCopilotChatHeadless_c",
},
{
sourcePath: "packages/react-ui/src/hooks/use-copilot-chat-suggestions.tsx",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/hooks/useCopilotChatSuggestions.mdx",
hook: "useCopilotChatSuggestions",
description:
"The useCopilotChatSuggestions hook generates suggestions in the chat window based on real-time app state.",
},
{
sourcePath: "packages/react-core/src/hooks/use-copilot-readable.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/hooks/useCopilotReadable.mdx",
hook: "useCopilotReadable",
description:
"The useCopilotReadable hook allows you to provide knowledge to your copilot (e.g. application state).",
},
{
sourcePath: "packages/react-core/src/hooks/use-coagent-state-render.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/hooks/useCoAgentStateRender.mdx",
hook: "useCoAgentStateRender",
description:
"The useCoAgentStateRender hook allows you to render the state of the agent in the chat.",
},
{
sourcePath: "packages/react-core/src/hooks/use-coagent.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/hooks/useCoAgent.mdx",
hook: "useCoAgent",
description:
"The useCoAgent hook allows you to share state bidirectionally between your application and the agent.",
},
{
sourcePath:
"packages/react-core/src/hooks/use-copilot-additional-instructions.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/v1/hooks/useCopilotAdditionalInstructions.mdx",
hook: "useCopilotAdditionalInstructions",
description:
"The useCopilotAdditionalInstructions hook allows you to provide additional instructions to the agent.",
},
/* SDKs */
{
sourcePath: "sdk-python/copilotkit/langgraph.py",
destinationPath:
"showcase/shell-docs/src/content/reference/sdk/python/LangGraph.mdx",
title: "LangGraph SDK",
description:
"The CopilotKit LangGraph SDK for Python allows you to build and run LangGraph workflows with CopilotKit.",
pythonSymbols: [
"copilotkit_customize_config",
"copilotkit_exit",
"copilotkit_emit_state",
"copilotkit_emit_message",
"copilotkit_emit_tool_call",
],
},
{
sourcePath: "sdk-python/copilotkit/crewai/crewai_sdk.py",
destinationPath:
"showcase/shell-docs/src/content/reference/sdk/python/CrewAI.mdx",
title: "CrewAI SDK",
description:
"The CopilotKit CrewAI SDK for Python allows you to build and run CrewAI agents with CopilotKit.",
pythonSymbols: [
"copilotkit_emit_state",
"copilotkit_predict_state",
"copilotkit_exit",
"copilotkit_emit_message",
"copilotkit_emit_tool_call",
],
},
/* Agents */
{
sourcePath: "sdk-python/copilotkit/langgraph_agui_agent.py",
destinationPath:
"showcase/shell-docs/src/content/reference/sdk/python/LangGraphAGUIAgent.mdx",
title: "LangGraphAGUIAgent",
description:
"LangGraphAGUIAgent lets you define your agent for use with CopilotKit.",
pythonSymbols: ["LangGraphAGUIAgent", "CopilotKitConfig"],
},
{
sourcePath: "sdk-python/copilotkit/crewai/crewai_agent.py",
destinationPath:
"showcase/shell-docs/src/content/reference/sdk/python/CrewAIAgent.mdx",
title: "CrewAIAgent",
description:
"CrewAIAgent lets you define your agent for use with CopilotKit.",
pythonSymbols: ["CrewAIAgent", "CopilotKitConfig"],
},
{
sourcePath: "sdk-python/copilotkit/sdk.py",
destinationPath:
"showcase/shell-docs/src/content/reference/sdk/python/RemoteEndpoints.mdx",
title: "Remote Endpoints",
description:
"CopilotKit Remote Endpoints allow you to connect actions and agents written in Python to your CopilotKit application.",
pythonSymbols: ["CopilotKitRemoteEndpoint", "CopilotKitContext"],
},
{
sourcePath: "packages/sdk-js/src/langgraph/index.ts",
destinationPath:
"showcase/shell-docs/src/content/reference/sdk/js/LangGraph.mdx",
title: "LangGraph SDK",
description:
"The CopilotKit LangGraph SDK for JavaScript allows you to build and run LangGraph workflows with CopilotKit.",
typescriptSymbols: [
"copilotkitCustomizeConfig",
"copilotkitExit",
"copilotkitEmitState",
"copilotkitEmitMessage",
"copilotkitEmitToolCall",
],
},
];
+192
View File
@@ -0,0 +1,192 @@
interface ParsedFunctionDoc {
description: string;
parameters: Array<{
name: string;
type: string;
description: string;
}>;
returns: {
type: string;
description: string;
} | null;
}
/**
* Return an array of parameter objects from a block of text that
* looks like a NumPy docstring parameters section.
*/
function parseParameters(rawParameters: string) {
// Split by lines.
const lines = rawParameters.split("\n");
interface Param {
name: string;
type: string;
description: string;
}
const parameters: Param[] = [];
let currentParam: Param | null = null;
// Regex for a parameter heading, e.g.:
// base_config : Optional[RunnableConfig]
// or with indentation, e.g.:
// base_config : Optional[RunnableConfig]
const paramHeadingRegex = /^[ \t]*([A-Za-z_]\w*)\s*:\s*(.*)$/;
for (const line of lines) {
const trimmedLine = line.trim();
// 1) If line matches the heading format, that's a *new* parameter
const headingMatch = line.match(paramHeadingRegex);
if (headingMatch) {
// If we were building a previous param, push it first
if (currentParam) {
parameters.push(currentParam);
}
// Start a new param
const pName = headingMatch[1];
const pType = headingMatch[2];
currentParam = {
name: pName.trim(),
type: pType.trim(),
description: "", // well accumulate description lines
};
continue;
}
// 2) If its not a heading line and we have a current param, treat it as description
if (currentParam && trimmedLine) {
// Add a space if we already have some text
if (currentParam.description.length > 0) {
currentParam.description += " ";
}
currentParam.description += trimmedLine;
}
}
// Push the last param if we have one
if (currentParam) {
parameters.push(currentParam);
}
return parameters;
}
/**
* Parses out function docstrings for the given Python functions (by name).
*
* @param functionNames - The names of the functions to parse
* @param fileContent - The entire Python file content
* @returns A record where each key is a function name, and the value is the parsed doc info
*/
export function parsePythonDocstrings(
functionNames: string[],
fileContent: string,
): Record<string, ParsedFunctionDoc> {
const results: Record<string, ParsedFunctionDoc> = {};
// Regex to capture:
// 1) Optional leading "async"
// 2) `def`
// 3) function name
// 4) Anything until the `"""` docstring start
// 5) The content inside the triple quotes
//
// The `[\s\S]` is used so that `.` can match newlines.
// The `?` in `[\s\S]*?` makes it non-greedy so we capture the smallest triple-quote block.
// The `m` flag is used so ^ can match the start of lines.
// The `g` flag is for capturing all occurrences.
//
// We also add a lookbehind for ) or : to ensure we match the pattern of a function signature,
// but you can tweak as needed.
// Updated regex to handle multiline signatures
const functionRegex =
/\b(?:async\s+)?(def|class)\s+([A-Za-z_]\w*)[\s\S]*?"""([\s\S]*?)"""/gm;
let match: RegExpExecArray | null;
while ((match = functionRegex.exec(fileContent)) !== null) {
const fnName = match[2];
const docstring = match[3];
// Only parse if the function is in functionNames
if (!functionNames.includes(fnName)) {
continue;
}
// 1) Split docstring by "Parameters" and/or "Returns" blocks
// We'll do a very naive parse in NumPy style:
//
// description (until we see the line "Parameters" or "Returns")
// (optional) Parameters
// (optional) Returns
//
// Example NumPy-ish block:
//
// Parameters
// ----------
// param1 : str
// Description...
// param2 : int
// ...
//
// Returns
// -------
// int
// Some description...
//
// We'll break it up with a simple approach:
const [rawDescription, maybeParamsAndBeyond = ""] = docstring.split(
/\n\s*Parameters\s*[-=]+\s*\n/, // Splits after 'Parameters'
);
let rawParameters = "";
let rawReturns = "";
// Check if there's a "Returns" block in the "maybeParamsAndBeyond" chunk
const returnsSplit = maybeParamsAndBeyond.split(
/\n\s*Returns\s*[-=]+\s*\n/,
);
if (returnsSplit.length === 2) {
// [ paramsBlock, returnsBlock ]
rawParameters = returnsSplit[0];
rawReturns = returnsSplit[1];
} else {
// no Returns block found
rawParameters = maybeParamsAndBeyond;
}
// 2) Parse description: everything up to "Parameters"
const description = rawDescription.trim();
// 3) Parse parameters from rawParameters
const parameters = parseParameters(rawParameters);
// 4) Parse returns from rawReturns
//
// Returns
// -------
// ReturnType
// Description ...
//
// We'll do something simple: get the first line as type, the rest as the description
let returnType = "";
let returnDescription = "";
if (rawReturns.trim()) {
// The first non-empty line is the type
const lines = rawReturns.split("\n").map((l) => l.trim());
returnType = lines[0];
// The rest is the description
returnDescription = lines.slice(1).join(" ");
}
results[fnName] = {
description,
parameters,
returns: returnType
? { type: returnType, description: returnDescription.trim() }
: null,
};
}
return results;
}
+244
View File
@@ -0,0 +1,244 @@
import { SourceFile } from "./source";
import { Comments } from "./comments";
// @ts-ignore
import fs from "fs";
import { parsePythonDocstrings } from "./python";
export interface ReferenceDocConfiguration {
sourcePath: string;
destinationPath: string;
className?: string;
component?: string;
hook?: string;
description?: string;
title?: string;
pythonSymbols?: string[];
typescriptSymbols?: string[];
}
export class ReferenceDoc {
constructor(private readonly referenceDoc: ReferenceDocConfiguration) {}
async generate() {
let generatedDocumentation: string | null = null;
if (this.referenceDoc.pythonSymbols) {
generatedDocumentation = this.generatedDocsPythonSymbols();
} else if (this.referenceDoc.typescriptSymbols) {
generatedDocumentation = await this.generatedDocsTypeScriptSymbols();
} else {
generatedDocumentation = await this.generatedDocsTypeScript();
}
if (generatedDocumentation) {
const dest = this.referenceDoc.destinationPath;
// Ensure parent directory exists so retargeting to a fresh tree
// (e.g. shell-docs) doesn't require manual mkdir.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fs as any).mkdirSync(dest.split("/").slice(0, -1).join("/"), {
recursive: true,
});
fs.writeFileSync(dest, generatedDocumentation);
console.log(
`Successfully autogenerated ${dest} from ${this.referenceDoc.sourcePath}`,
);
}
}
generateTitle(
title: string,
description: string,
sourcePath: string,
): string {
let result = `---\n`;
result += `title: "${title}"\n`;
if (description) {
result += `description: "${description}"\n`;
}
result += `---\n\n`;
result += `{\n`;
result += ` /*\n`;
result += ` * ATTENTION! DO NOT MODIFY THIS FILE!\n`;
result += ` * This page is auto-generated. If you want to make any changes to this page, changes must be made at:\n`;
result += ` * ${sourcePath}\n`;
result += ` */\n`;
result += `}\n`;
return result;
}
generatedDocsPythonSymbols(): string | null {
const content = fs.readFileSync(this.referenceDoc.sourcePath, "utf8");
const parsed = parsePythonDocstrings(
this.referenceDoc.pythonSymbols!,
content,
);
let result = this.generateTitle(
this.referenceDoc.title || "",
this.referenceDoc.description || "",
this.referenceDoc.sourcePath,
);
for (const fn of this.referenceDoc.pythonSymbols!) {
if (fn in parsed) {
const fnDoc = parsed[fn];
result += `## ${fn}\n\n`;
result += `${fnDoc.description}\n\n`;
if (fnDoc.parameters) {
result += `### Parameters\n\n`;
for (const param of fnDoc.parameters) {
const required = !param.type.startsWith("Optional");
result += `<PropertyReference name="${param.name}" type="${param.type}" ${required ? "required" : ""}> \n`;
result += `${param.description}\n`;
result += `</PropertyReference>\n\n`;
}
}
if (fnDoc.returns) {
result += `### Returns\n\n`;
result += `<PropertyReference name="returns" type="${fnDoc.returns.type}">\n`;
result += `${fnDoc.returns.description}\n`;
result += `</PropertyReference>\n\n`;
}
}
}
return result;
}
async generatedDocsTypeScriptSymbols(): Promise<string | null> {
const source = new SourceFile(this.referenceDoc.sourcePath);
await source.parse();
let result = this.generateTitle(
this.referenceDoc.title || "",
this.referenceDoc.description || "",
this.referenceDoc.sourcePath,
);
for (const fn of this.referenceDoc.typescriptSymbols!) {
const args = source.getFunctionArguments(fn);
if (!args) {
console.warn(`${fn} not found in ${this.referenceDoc.sourcePath}`);
continue;
}
const comment = source.getFunctionComment(fn);
result += `## ${fn}\n\n`;
if (comment) {
result += `${comment}\n\n`;
}
if (args) {
result += `### Parameters\n\n`;
for (const arg of args) {
result += `<PropertyReference name="${arg.name}" type="${arg.type}" ${arg.required ? "required" : ""} ${arg.defaultValue ? `default="${arg.defaultValue}"` : ""}> \n`;
result += `${arg.description}\n`;
result += `</PropertyReference>\n\n`;
}
}
}
return result;
}
async generatedDocsTypeScript(): Promise<string | null> {
const source = new SourceFile(this.referenceDoc.sourcePath);
await source.parse();
const comment = Comments.getFirstCommentBlock(source.sourceFile);
if (!comment) {
console.warn(`No comment found for ${this.referenceDoc.sourcePath}`);
console.warn("Skipping...");
return null;
}
// handle imports
const slashes = this.referenceDoc.destinationPath.split("/").length;
let importPathPrefix = "";
for (let i = 0; i < slashes - 2; i++) {
importPathPrefix += "../";
}
let result = this.generateTitle(
this.referenceDoc.className ||
this.referenceDoc.component ||
this.referenceDoc.hook ||
"",
this.referenceDoc.description || "",
this.referenceDoc.sourcePath,
);
result += `${comment}\n\n`;
const arg0Interface = await source.getArg0Interface(
this.referenceDoc.className ||
this.referenceDoc.component ||
this.referenceDoc.hook ||
"",
);
if (arg0Interface) {
const hasProperties = arg0Interface.properties.length > 0;
if (this.referenceDoc.hook && hasProperties) {
result += `## Parameters\n\n`;
} else if (this.referenceDoc.component && hasProperties) {
result += `## Properties\n\n`;
} else if (this.referenceDoc.className && hasProperties) {
result += `## Constructor Parameters\n\n`;
}
for (const property of arg0Interface.properties) {
if (property.comment.includes("@deprecated")) {
continue;
}
const type = property.type.replace(/"/g, "'");
result += `<PropertyReference name="${property.name}" type="${type}" ${property.required ? "required" : ""} ${property.defaultValue ? `default="${property.defaultValue}"` : ""}> \n`;
result += `${property.comment}\n`;
result += `</PropertyReference>\n\n`;
}
} else if (this.referenceDoc.className) {
const constr = source.getConstructorDefinition(
this.referenceDoc.className,
);
if (constr) {
result += `## ${constr.signature}\n\n`;
result += `${constr.comment}\n\n`;
for (const param of constr.parameters) {
const type = param.type.replace(/"/g, "'");
result += `<PropertyReference name="${param.name}" type="${type}" ${param.required ? "required" : ""}>\n`;
result += `${param.comment}\n`;
result += `</PropertyReference>\n\n`;
}
}
}
if (this.referenceDoc.className) {
const methodDefinitions = await source.getPublicMethodDefinitions(
this.referenceDoc.className,
);
for (const method of methodDefinitions) {
if (
method.signature ===
"process(request: CopilotRuntimeChatCompletionRequest)" ||
method.signature === "process(request: CopilotRuntimeRequest)"
) {
// skip the process method
continue;
}
const methodName = method.signature.split("(")[0];
const methodArgs = method.signature.split("(")[1].split(")")[0];
result += `<PropertyReference name="${methodName}" type="${methodArgs}">\n`;
result += `${method.comment}\n\n`;
for (const param of method.parameters) {
const type = param.type.replace(/"/g, "'");
result += ` <PropertyReference name="${param.name}" type="${type}" ${param.required ? "required" : ""}>\n`;
result += ` ${param.comment}\n`;
result += ` </PropertyReference>\n\n`;
}
result += `</PropertyReference>\n\n`;
}
}
return result;
}
}
+411
View File
@@ -0,0 +1,411 @@
import * as ts from "typescript";
// @ts-ignore
import * as fs from "fs";
// @ts-ignore
import { existsSync } from "fs";
// @ts-ignore
import { dirname, resolve } from "path";
import { Comments } from "./comments";
export interface InterfaceDefinition {
name: string;
properties: {
name: string;
type: string;
required: boolean;
comment: string;
defaultValue?: string;
}[];
}
export interface MethodDefinition {
signature: string;
comment: string;
parameters: {
name: string;
type: string;
required: boolean;
comment: string;
}[];
}
export class SourceFile {
public sourceFile!: ts.SourceFile;
constructor(private readonly filePath: string) {}
parse() {
const fileContents = fs.readFileSync(this.filePath, "utf8");
this.sourceFile = ts.createSourceFile(
this.filePath,
fileContents,
ts.ScriptTarget.Latest,
true,
);
}
/**
* Get the interface definition of the first argument of the function or class constructor.
*/
getArg0Interface(name: string): InterfaceDefinition | null {
let interfaceName: string = "";
const visit = (node: ts.Node) => {
// if we find a matching function declaration
if (
ts.isFunctionDeclaration(node) &&
node.name?.getText() === name &&
node.parameters.length &&
node.parameters[0].type &&
ts.isTypeReferenceNode(node.parameters[0].type)
) {
interfaceName = node.parameters[0].type.typeName.getText();
}
// if we find a matching class declaration
else if (ts.isClassDeclaration(node) && node.name?.getText() === name) {
const constructor = node.members.find((member) =>
ts.isConstructorDeclaration(member),
) as ts.ConstructorDeclaration;
if (
constructor &&
constructor.parameters.length &&
constructor.parameters[0].type &&
ts.isTypeReferenceNode(constructor.parameters[0].type)
) {
interfaceName = constructor.parameters[0].type.typeName.getText();
}
}
// if we find a matching forwardRef declaration
else if (ts.isVariableStatement(node) || ts.isVariableDeclaration(node)) {
const declarations = ts.isVariableStatement(node)
? node.declarationList.declarations
: [node];
declarations.forEach((declaration) => {
if (
ts.isVariableDeclaration(declaration) &&
declaration.name.getText() === name &&
declaration.initializer &&
ts.isCallExpression(declaration.initializer) &&
declaration.initializer.expression.getText() === "React.forwardRef"
) {
const func = declaration.initializer.arguments[0];
if (
ts.isArrowFunction(func) &&
func.parameters.length &&
func.parameters[0].type &&
ts.isTypeReferenceNode(func.parameters[0].type)
) {
interfaceName = func.parameters[0].type.typeName.getText();
}
}
});
}
ts.forEachChild(node, visit);
};
// analyze the source file
visit(this.sourceFile);
if (!interfaceName) {
return null;
}
// extract the interface definition
let interfaceFilePath =
this.findTypeDeclaration(interfaceName) || this.filePath;
const interfaceSource = new SourceFile(interfaceFilePath);
interfaceSource.parse();
return interfaceSource.extractInterfaceDefinition(interfaceName);
}
/**
* Extracts the interface definition from the source file.
*/
protected extractInterfaceDefinition(
interfaceName: string,
): InterfaceDefinition {
const definition: InterfaceDefinition = {
name: interfaceName,
properties: [],
};
const visit = (node: ts.Node) => {
if (ts.isInterfaceDeclaration(node) && node.name.text === interfaceName) {
let omittedProperties: Set<string> = new Set();
// Check for extended interfaces
if (node.heritageClauses && node.heritageClauses.length > 0) {
const firstClause = node.heritageClauses[0];
firstClause.types.forEach((type) => {
const typeName = type.expression.getText(this.sourceFile);
let extendedInterfaceName = typeName;
// Check if the type is an Omit
if (typeName.startsWith("Omit")) {
const omitArgs = type.typeArguments;
if (omitArgs && omitArgs.length > 0) {
extendedInterfaceName = omitArgs[0].getText(this.sourceFile);
if (omitArgs.length > 1) {
const omittedProps = omitArgs[1];
if (ts.isUnionTypeNode(omittedProps)) {
omittedProps.types.forEach((prop) => {
omittedProperties.add(
prop.getText(this.sourceFile).replace(/['"]/g, ""),
);
});
} else if (ts.isLiteralTypeNode(omittedProps)) {
omittedProperties.add(
omittedProps
.getText(this.sourceFile)
.replace(/['"]/g, ""),
);
}
}
}
}
const extendedInterfaceFilePath = this.findTypeDeclaration(
extendedInterfaceName,
);
if (extendedInterfaceFilePath) {
// Parse the extended interface file and extract its definition
const extendedInterfaceSource = new SourceFile(
extendedInterfaceFilePath,
);
extendedInterfaceSource.parse();
const extendedDefinition =
extendedInterfaceSource.extractInterfaceDefinition(
extendedInterfaceName,
);
// Merge properties from the extended interface, excluding omitted properties
extendedDefinition.properties.forEach((prop) => {
if (!omittedProperties.has(prop.name)) {
definition.properties.push(prop);
}
});
}
});
}
node.members.forEach((member) => {
if (ts.isPropertySignature(member)) {
const propertyName = member.name.getText(this.sourceFile);
const comment = Comments.getCleanedCommentsForNode(
member,
this.sourceFile,
);
const defaultValue = Comments.getDefaultValueForNode(
member,
this.sourceFile,
);
definition.properties.push({
name: propertyName,
type: (member.type?.getText(this.sourceFile) || "unknown")
.replace(/\n/g, "")
.replace(/\s+/g, " "),
required: !member.questionToken,
comment,
defaultValue,
});
}
});
}
ts.forEachChild(node, visit);
};
visit(this.sourceFile);
return definition;
}
/**
* Finds the absolute declaration file path of a type if imported.
*/
findTypeDeclaration(typeName: string): string | null {
for (const statement of this.sourceFile.statements) {
if (ts.isImportDeclaration(statement) && statement.importClause) {
const namedBindings = statement.importClause.namedBindings;
if (namedBindings && ts.isNamedImports(namedBindings)) {
const imports = namedBindings.elements.filter(
(element) => element.name.text === typeName,
);
if (imports.length > 0) {
const moduleSpecifier = (
statement.moduleSpecifier as ts.StringLiteral
).text;
// Resolve the path relative to the directory of the current source file
let resolvedPath = resolve(
dirname(this.sourceFile.fileName),
moduleSpecifier,
);
if (existsSync(resolvedPath + ".ts")) {
return resolvedPath + ".ts";
} else if (existsSync(resolvedPath + ".tsx")) {
return resolvedPath + ".tsx";
}
return null;
}
}
}
}
return null;
}
/**
* Get the public method definitions of a class.
*/
getPublicMethodDefinitions(className: string): MethodDefinition[] {
const methodDefinitions: MethodDefinition[] = [];
const visit = (node: ts.Node) => {
if (ts.isClassDeclaration(node) && node.name?.getText() === className) {
node.members.forEach((member) => {
if (
ts.isMethodDeclaration(member) &&
member.modifiers?.every(
(modifier) => modifier.kind !== ts.SyntaxKind.PrivateKeyword,
)
) {
methodDefinitions.push(this.extractMethodDefinition(member));
}
});
}
ts.forEachChild(node, visit);
};
visit(this.sourceFile);
return methodDefinitions;
}
/**
* Extracts the method definition from a method declaration.
*/
private extractMethodDefinition(
member: ts.MethodDeclaration,
): MethodDefinition {
const functionComments = Comments.getTsDocCommentsForFunction(
member,
this.sourceFile,
);
const name = ts.isConstructorDeclaration(member)
? "constructor"
: member.name.getText();
let signature =
name +
"(" +
member.parameters.map((param) => param.getText()).join(", ") +
")";
signature = signature.replace(/</g, "&lt;").replace(/>/g, "&gt;");
return {
signature,
comment: functionComments.comment,
parameters: member.parameters.map((param) => {
return {
name: param.name.getText(),
type: param.type?.getText() || "unknown",
required: !param.questionToken,
comment: functionComments.params[param.name.getText()] || "",
};
}),
};
}
/**
* Get the constructor definition of a class.
*/
getConstructorDefinition(className: string): MethodDefinition | null {
let constructorDefinition: MethodDefinition | null = null;
const visit = (node: ts.Node) => {
if (ts.isClassDeclaration(node) && node.name?.getText() === className) {
const constr = node.members.find((member) =>
ts.isConstructorDeclaration(member),
) as any;
if (constr) {
constructorDefinition = this.extractMethodDefinition(constr);
}
}
ts.forEachChild(node, visit);
};
visit(this.sourceFile);
return constructorDefinition;
}
/**
* Get the block comment preceding a function definition.
*/
getFunctionComment(functionName: string): string | null {
let functionComment: string | null = null;
const visit = (node: ts.Node) => {
if (
ts.isFunctionDeclaration(node) &&
node.name?.getText() === functionName
) {
functionComment = Comments.getCleanedCommentsForNode(
node,
this.sourceFile,
);
}
ts.forEachChild(node, visit);
};
visit(this.sourceFile);
return functionComment;
}
/**
* Get the arguments of a function by its name.
*/
getFunctionArguments(functionName: string): Array<{
name: string;
type: string;
required: boolean;
defaultValue: string;
description: string;
}> | null {
let functionArguments: Array<{
name: string;
type: string;
required: boolean;
defaultValue: string;
description: string;
}> | null = null;
const visit = (node: ts.Node) => {
if (
ts.isFunctionDeclaration(node) &&
node.name?.getText() === functionName
) {
functionArguments = node.parameters.map((param) => {
const name = param.name.getText();
const type = param.type?.getText() || "unknown";
const required = !param.questionToken;
const defaultValue = param.initializer
? param.initializer.getText()
: "";
// Use the comment cleaning method
const description = Comments.getCleanedCommentsForNode(
param,
this.sourceFile,
);
return { name, type, required, defaultValue, description };
});
}
ts.forEachChild(node, visit);
};
visit(this.sourceFile);
return functionArguments;
}
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Rejects staged binary artifacts, build output, dSYM dirs, and files > 1 MB.
# Invoked by lefthook pre-commit. Lives in a standalone file so Windows Git Bash
# doesn't mangle the quoting when lefthook passes it through `sh.exe -c`.
set -eu
VIOLATIONS=0
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$STAGED" ] && exit 0
BINARIES=$(echo "$STAGED" | grep -iE '\.(exe|dll|so|dylib|o|obj|a|lib|wasm)$' || true)
if [ -n "$BINARIES" ]; then
echo "Binary files detected:"
echo "$BINARIES"
VIOLATIONS=1
fi
BUILD=$(echo "$STAGED" | grep -E '/build/' || true)
if [ -n "$BUILD" ]; then
echo "Files in build directories:"
echo "$BUILD"
VIOLATIONS=1
fi
DSYM=$(echo "$STAGED" | grep -E '\.dSYM/' || true)
if [ -n "$DSYM" ]; then
echo "dSYM directories:"
echo "$DSYM"
VIOLATIONS=1
fi
while IFS= read -r f; do
[ -z "$f" ] && continue
[ ! -f "$f" ] && continue
# Skip lockfiles and a small set of generated data files that legitimately
# exceed 1 MB (showcase demo/search/starter content). Keeping the list
# explicit — any other file over 1 MB still gets rejected.
case "$f" in
pnpm-lock.yaml|*/package-lock.json) continue ;;
showcase/shell/src/data/demo-content.json) continue ;;
showcase/shell-docs/src/data/demo-content.json) continue ;;
showcase/shell-dojo/src/data/demo-content.json) continue ;;
showcase/shell/src/data/search-index.json) continue ;;
showcase/shell/src/data/starter-content.json) continue ;;
# shell-docs and shell-dojo mirror the same generated demo-content
# bundle as shell/ -- they're produced by the same
# `scripts/bundle-demo-content.ts` run and legitimately exceed 1 MB.
showcase/shell-docs/src/data/demo-content.json) continue ;;
showcase/shell-docs/src/data/search-index.json) continue ;;
showcase/shell-docs/src/data/starter-content.json) continue ;;
showcase/shell-dojo/src/data/demo-content.json) continue ;;
showcase/shell-dojo/src/data/search-index.json) continue ;;
showcase/shell-dojo/src/data/starter-content.json) continue ;;
esac
SIZE=$(wc -c < "$f" | tr -d ' ')
[ -z "$SIZE" ] && continue
if [ "$SIZE" -gt 1048576 ]; then
echo "Oversized file: $f ($((SIZE / 1024)) KB)"
VIOLATIONS=1
fi
done <<< "$STAGED"
# Explicit `if … then` (instead of `[ … ] && exit 1`) to avoid the brittle
# `set -e` interaction: with errexit enabled, a failing simple command as
# the penultimate line is only safe because the trailing `exit 0` follows.
# The explicit form is robust regardless of what comes after.
if [ "$VIOLATIONS" -eq 1 ]; then
exit 1
fi
exit 0
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env bash
set -euo pipefail
# Migration script: shallow clone demo repos → copy into examples/
# Usage: ./scripts/migrate-demos.sh [--group A|B|C|D|all] [--dry-run]
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CLONE_DIR="$(mktemp -d)"
trap 'rm -rf "$CLONE_DIR"' EXIT
GROUP="${1:---group}"
GROUP_FILTER="${2:-all}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--group) GROUP_FILTER="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
*) shift ;;
esac
done
# Track warnings for post-run summary
WARNINGS=()
MIGRATED=0
FAILED=0
warn() { WARNINGS+=("$1"); echo " WARNING: $1"; }
migrate_repo() {
local repo="$1"
local target_path="$2"
local branch="${3:-}"
local full_target="$REPO_ROOT/$target_path"
echo "==> Migrating $repo$target_path"
if [[ -d "$full_target" ]]; then
echo " SKIP: $full_target already exists"
return 0
fi
# Step 1: Shallow clone
local clone_dest="$CLONE_DIR/$repo"
rm -rf "$clone_dest"
local clone_args=(--depth 1)
if [[ -n "$branch" ]]; then
clone_args+=(--branch "$branch")
fi
if ! git clone "${clone_args[@]}" "https://github.com/CopilotKit/${repo}.git" "$clone_dest" 2>/dev/null; then
echo " FAILED: Could not clone $repo"
((FAILED++))
return 1
fi
# Step 2: Cleanup
rm -rf "$clone_dest/.git"
rm -rf "$clone_dest/.github"
rm -f "$clone_dest/renovate.json"
rm -f "$clone_dest/netlify.toml"
rm -f "$clone_dest/_redirects"
rm -f "$clone_dest/_headers"
find "$clone_dest" -type d -name "node_modules" -exec rm -rf {} + 2>/dev/null || true
# Remove .env files but keep .env.example
find "$clone_dest" -name ".env" -not -name ".env.example" -delete 2>/dev/null || true
# Also remove .env.local, .env.development.local, etc. but not .env.example
find "$clone_dest" -name ".env.*" -not -name ".env.example" -not -name ".env.*.example" -delete 2>/dev/null || true
# Step 3: Scan for large files not covered by LFS
while IFS= read -r -d '' f; do
local ext="${f##*.}"
local ext_lower
ext_lower=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
case "$ext_lower" in
gif|jpg|jpeg|png|pdf|mp4|webm|svg) ;; # Covered by .gitattributes
*) warn "$target_path: Large file not LFS-tracked: ${f#$clone_dest/} ($(du -h "$f" | cut -f1))" ;;
esac
done < <(find "$clone_dest" -type f -size +1M -print0 2>/dev/null)
if [[ $DRY_RUN -eq 1 ]]; then
echo " DRY RUN: Would copy to $full_target"
((MIGRATED++))
rm -rf "$clone_dest"
return 0
fi
# Step 4: Copy to target
mkdir -p "$(dirname "$full_target")"
cp -a "$clone_dest" "$full_target"
# Step 5: Clean up clone
rm -rf "$clone_dest"
((MIGRATED++))
echo " OK: $target_path"
}
# ============================================================
# MANIFEST: All 47 repos organized by group
# Format: migrate_repo <github-repo-name> <target-path> [branch]
# ============================================================
migrate_group_a() {
echo ""
echo "========== GROUP A: Demo Team Repos (22) =========="
echo ""
migrate_repo with-langgraph-python examples/integrations/langgraph-python
migrate_repo with-langgraph-js examples/integrations/langgraph-js
migrate_repo with-langgraph-fastapi examples/integrations/langgraph-fastapi
migrate_repo with-mastra examples/integrations/mastra
migrate_repo with-crewai-flows examples/integrations/crewai-flows
migrate_repo with-llamaindex examples/integrations/llamaindex
migrate_repo with-pydantic-ai examples/integrations/pydantic-ai
migrate_repo with-microsoft-agent-framework-python examples/integrations/ms-agent-framework-python
migrate_repo with-microsoft-agent-framework-dotnet examples/integrations/ms-agent-framework-dotnet
migrate_repo with-strands-python examples/integrations/strands-python
migrate_repo with-mcp-apps examples/integrations/mcp-apps
migrate_repo with-adk examples/integrations/adk
migrate_repo with-agent-spec examples/integrations/agent-spec
migrate_repo with-a2a-a2ui examples/integrations/a2a-a2ui
migrate_repo demo-banking examples/showcases/banking
migrate_repo demo-presentation examples/showcases/presentation
migrate_repo deep-agents-demo examples/showcases/deep-agents
migrate_repo deep-agents-job-search-assistant examples/showcases/deep-agents-job-search
migrate_repo generative-ui examples/showcases/generative-ui
migrate_repo generative-ui-playground examples/showcases/generative-ui-playground
migrate_repo mcp-apps-demo examples/showcases/mcp-apps
migrate_repo open-research-ANA examples/showcases/research-canvas
}
migrate_group_b() {
echo ""
echo "========== GROUP B: Additional Repos (21) =========="
echo ""
# Integration starters
migrate_repo with-agno examples/integrations/agno
migrate_repo with-crewai-crews examples/integrations/crewai-crews
migrate_repo with-a2a-middleware examples/integrations/a2a-middleware
# Canvas demos
migrate_repo canvas-with-langgraph-python examples/canvas/langgraph-python
migrate_repo canvas-with-llamaindex examples/canvas/llamaindex
migrate_repo canvas-with-llamaindex-composio examples/canvas/llamaindex-composio
migrate_repo canvas-with-pydantic-ai examples/canvas/pydantic-ai
migrate_repo canvas-with-mastra examples/canvas/mastra
# Feature demos
migrate_repo copilotkit-mcp-demo examples/showcases/mcp-demo
migrate_repo strands-file-analyzer-demo examples/showcases/strands-file-analyzer
migrate_repo microsoft-kanban-demo examples/showcases/microsoft-kanban
migrate_repo multi-page-demo examples/showcases/multi-page
migrate_repo orca-CopilotKit-demo examples/showcases/orca
# Showcases
migrate_repo pydantic-ai-todos examples/showcases/pydantic-ai-todos
migrate_repo scene-creator-copilot examples/showcases/scene-creator
migrate_repo open-gemini-canvas examples/canvas/gemini
migrate_repo adk-generative-dashboard examples/showcases/adk-dashboard
migrate_repo mastra-pm-canvas examples/canvas/mastra-pm
migrate_repo langgraph-js-support-agents examples/showcases/langgraph-js-support-agents
migrate_repo open-multi-agent-canvas examples/showcases/multi-agent-canvas
migrate_repo open-chatkit-studio examples/showcases/chatkit-studio
}
migrate_group_c() {
echo ""
echo "========== GROUP C: Remaining Repos (2) =========="
echo ""
migrate_repo enterprise-brex-demo examples/showcases/enterprise-brex
migrate_repo a2a-travel examples/showcases/a2a-travel
}
migrate_group_d() {
echo ""
echo "========== GROUP D: Markus Ecker Forks (2) =========="
echo ""
migrate_repo demo-spreadsheet examples/showcases/spreadsheet
migrate_repo demo-todo examples/showcases/todo
}
# ============================================================
# Main
# ============================================================
echo "Migration script starting..."
echo "Repo root: $REPO_ROOT"
echo "Temp dir: $CLONE_DIR"
echo "Group filter: $GROUP_FILTER"
echo "Dry run: $DRY_RUN"
echo ""
case "$GROUP_FILTER" in
A|a) migrate_group_a ;;
B|b) migrate_group_b ;;
C|c) migrate_group_c ;;
D|d) migrate_group_d ;;
all)
migrate_group_a
migrate_group_b
migrate_group_c
migrate_group_d
;;
*) echo "Unknown group: $GROUP_FILTER"; exit 1 ;;
esac
echo ""
echo "========== SUMMARY =========="
echo "Migrated: $MIGRATED"
echo "Failed: $FAILED"
if [[ ${#WARNINGS[@]} -gt 0 ]]; then
echo ""
echo "WARNINGS (${#WARNINGS[@]}):"
for w in "${WARNINGS[@]}"; do
echo " - $w"
done
fi
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
ACTIONS_APP_PATH="/tmp/test-actions-app"
echo "Creating next app in $ACTIONS_APP_PATH"
echo ""
# Remove prev project and run create-next-app
npx create-next-app $ACTIONS_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
npm_install_packages $ACTIONS_APP_PATH
cp -r scripts/qa/lib/actions $ACTIONS_APP_PATH/src/app/actions
# Open VSCode
code $ACTIONS_APP_PATH
prompt "Are all actions in the 'good' folder without errors in VSCode?"
prompt "Do all actions in the 'bad' folder have errors in VSCode?"
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
CSS_APP_PATH="/tmp/test-css-app"
echo "Creating next app in $CSS_APP_PATH"
echo ""
# Remove prev project and run create-next-app
rm -rf $CSS_APP_PATH
npx create-next-app $CSS_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $CSS_APP_PATH/.env
npm_install_packages $CSS_APP_PATH
cp scripts/qa/lib/css/page.tsx $CSS_APP_PATH/src/app/page.tsx
# Open VSCode
code $CSS_APP_PATH
mkdir -p $CSS_APP_PATH/src/app/api/copilotkit/openai/
cp scripts/qa/lib/css/route.ts $CSS_APP_PATH/src/app/api/copilotkit/openai/route.ts
# Temporarily disable -e
set +e
pushd $CSS_APP_PATH
npm run build
exit_status=$?
if [ $exit_status -eq 0 ]; then
succeed "$pkg_manager build succeeded."
else
fail "$pkg_manager build failed with status $exit_status."
exit 1
fi
# Re-enable -e
set -e
npm run dev > /dev/null 2>&1 &
pid1=$!
popd
prompt "Open http://localhost:3000. Does it load with custom colors and icons?"
killall next-server;
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
FIREBASE_APP_PATH="/tmp/test-firebase-app"
rm -rf $FIREBASE_APP_PATH
echo "Creating firebase app in $FIREBASE_APP_PATH"
# prepare the python app
mkdir -p $FIREBASE_APP_PATH
npx create-next-app $FIREBASE_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $FIREBASE_APP_PATH/.env
npm_install_packages $FIREBASE_APP_PATH
mkdir -p $FIREBASE_APP_PATH/functions
mkdir -p $FIREBASE_APP_PATH/functions/src
cp scripts/qa/lib/firebase/.firebaserc $FIREBASE_APP_PATH/.firebaserc
cp scripts/qa/lib/firebase/firebase.json $FIREBASE_APP_PATH/firebase.json
cp scripts/qa/lib/firebase/index.ts $FIREBASE_APP_PATH/functions/src/index.ts
cp scripts/qa/lib/firebase/package.json $FIREBASE_APP_PATH/functions/package.json
cp scripts/qa/lib/firebase/tsconfig.json $FIREBASE_APP_PATH/functions/tsconfig.json
cp scripts/qa/lib/firebase/page.tsx $FIREBASE_APP_PATH/src/app/page.tsx
npm_install_packages $FIREBASE_APP_PATH/functions
# Temporarily disable -e
set +e
pushd $FIREBASE_APP_PATH
npm run build
exit_status=$?
if [ $exit_status -eq 0 ]; then
succeed "$pkg_manager build succeeded."
else
fail "$pkg_manager build failed with status $exit_status."
exit 1
fi
# Re-enable -e
set -e
# Start next server
npm run dev > /dev/null 2>&1 &
pid1=$!
popd
# Start firebase
pushd $FIREBASE_APP_PATH/functions
npm run serve > /dev/null 2>&1 &
pid2=$!
popd
prompt "Open http://localhost:3000. Is the page without errors?"
prompt "Chat with it. Does it work?"
prompt "Ask it to change the message. Does it work?"
killall next-server;
cleanup;
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0;
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
LC_APP_PATH="/tmp/test-langchain-app"
echo "Creating next app in $LC_APP_PATH"
echo ""
# Remove prev project and run create-next-app
rm -rf $LC_APP_PATH
npx create-next-app $LC_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $LC_APP_PATH/.env
npm_install_packages $LC_APP_PATH
(cd $LC_APP_PATH && npm install @langchain/community@latest @langchain/core@latest @langchain/langgraph@latest @langchain/openai@latest)
cp scripts/qa/lib/langchain/page.tsx $LC_APP_PATH/src/app/page.tsx
# Open VSCode
code $LC_APP_PATH
mkdir -p $LC_APP_PATH/src/app/api/copilotkit/langchain/
cp scripts/qa/lib/langchain/route.ts $LC_APP_PATH/src/app/api/copilotkit/langchain/route.ts
prompt "Open route.ts. Is it without errors in VSCode?"
# Temporarily disable -e
set +e
pushd $LC_APP_PATH
npm run build
exit_status=$?
if [ $exit_status -eq 0 ]; then
succeed "$pkg_manager build succeeded."
else
fail "$pkg_manager build failed with status $exit_status."
exit 1
fi
# Re-enable -e
set -e
npm run dev > /dev/null 2>&1 &
pid1=$!
popd
prompt "Open http://localhost:3000. Is the page without errors?"
prompt "Chat to check if regular text and message history works (2x)?"
prompt "Ask the copilot to change the message. Is the message changed?"
prompt "Ask the copilot to change the message again. Is the message changed?"
prompt "Ask for a long message. Does the custom render work & stream?"
prompt "Does it provide the current message when asked?"
prompt "Test the keyboard shortcut cmd-\\ to open close the sidebar. Does it work?"
prompt "Does the text input autofocus when the sidebar is opened?"
prompt "In the text area, start a text about elephants. Does the autosuggestions work?"
killall next-server;
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
LANGSERVE_APP_PATH="/tmp/test-langserve-app"
LANGSERVE_PYTHON_APP_PATH="/tmp/test-langserve-app-python"
rm -rf $LANGSERVE_APP_PATH
rm -rf $LANGSERVE_PYTHON_APP_PATH
echo "Creating langserve app in $LANGSERVE_APP_PATH and $LANGSERVE_PYTHON_APP_PATH"
# prepare the python app
mkdir -p $LANGSERVE_PYTHON_APP_PATH
mkdir -p $LANGSERVE_PYTHON_APP_PATH/app
cp "scripts/qa/lib/langserve/requirements.txt" $LANGSERVE_PYTHON_APP_PATH
cp "scripts/qa/lib/langserve/app/server.py" $LANGSERVE_PYTHON_APP_PATH/app
pushd $LANGSERVE_PYTHON_APP_PATH
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $LANGSERVE_PYTHON_APP_PATH/.env
popd
mkdir -p $LANGSERVE_APP_PATH
npx create-next-app $LANGSERVE_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $LANGSERVE_APP_PATH/.env
npm_install_packages $LANGSERVE_APP_PATH
(cd $LANGSERVE_APP_PATH && npm install @langchain/community @langchain/core @langchain/langgraph @langchain/openai langchain openai --save)
cp scripts/qa/lib/langserve/next/page.tsx $LANGSERVE_APP_PATH/src/app/page.tsx
mkdir -p $LANGSERVE_APP_PATH/src/app/api/copilotkit/openai/
cp scripts/qa/lib/langserve/next/route.ts $LANGSERVE_APP_PATH/src/app/api/copilotkit/openai/route.ts
# Open VSCode
code $LANGSERVE_APP_PATH
prompt "Open route.ts. Is it without errors in VSCode?"
# Temporarily disable -e
set +e
pushd $LANGSERVE_APP_PATH
npm run build
exit_status=$?
if [ $exit_status -eq 0 ]; then
succeed "$pkg_manager build succeeded."
else
fail "$pkg_manager build failed with status $exit_status."
exit 1
fi
# Re-enable -e
set -e
# Start next server
npm run dev > /dev/null 2>&1 &
next_pid=$!
popd
# Start python server
pushd $LANGSERVE_PYTHON_APP_PATH
python app/server.py > /dev/null 2>&1 &
python_pid=$!
popd
prompt "Open http://localhost:3000. Is the page without errors?"
prompt "Ask it to say hello to a name. Does it say hello in a ridiculous way?"
prompt "Ask it what dogs like to check langserve. Does it say sticks?"
prompt "Ask it what Eugene thinks about cats. Does it say cats like fish?"
killall next-server;
cleanup;
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0;
+29
View File
@@ -0,0 +1,29 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "object",
parameters: [
{
name: "arg",
type: "object[]",
description: "The object argument to display.",
attributes: [
{
name: "x",
type: "string",
description: "The x attribute.",
},
{
name: "y",
type: "number",
description: "The y attribute.",
},
],
},
],
handler: async ({ arg }) => {
const x: string = arg[0].x;
const y: number = arg[0].y;
const z: boolean = arg[0].z;
},
});
+26
View File
@@ -0,0 +1,26 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "enum",
parameters: [
{
name: "arg",
type: "string",
description: "The enum to display.",
enum: ["one", "two", "three"],
},
],
handler: async ({ arg }) => {
switch (arg) {
case "one":
console.log("One");
break;
case "two":
console.log("Two");
break;
default:
const exhaustiveCheck: never = arg;
}
console.log("No args action");
},
});
+8
View File
@@ -0,0 +1,8 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "noargs",
handler: async (args) => {
console.log("No args action");
},
});
+29
View File
@@ -0,0 +1,29 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "object",
parameters: [
{
name: "arg",
type: "object",
description: "The object argument to display.",
attributes: [
{
name: "x",
type: "string",
description: "The x attribute.",
},
{
name: "y",
type: "number",
description: "The y attribute.",
},
],
},
],
handler: async ({ arg }) => {
const x: string = arg.x;
const y: number = arg.y;
const z: boolean = arg.z;
},
});
+17
View File
@@ -0,0 +1,17 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "optional",
parameters: [
{
name: "arg",
type: "string",
description: "The optional argument to display.",
required: false,
},
],
handler: async ({ arg }) => {
// TODO this should fail
let x: string = arg;
},
});
+28
View File
@@ -0,0 +1,28 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "array",
parameters: [
{
name: "arg",
type: "object[]",
description: "The object argument to display.",
attributes: [
{
name: "x",
type: "string",
description: "The x attribute.",
},
{
name: "y",
type: "number",
description: "The y attribute.",
},
],
},
],
handler: async ({ arg }) => {
const x: string = arg[0].x;
const y: number = arg[0].y;
},
});
+29
View File
@@ -0,0 +1,29 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "enum",
parameters: [
{
name: "arg",
type: "string",
description: "The enum to display.",
enum: ["one", "two", "three"],
},
],
handler: async ({ arg }) => {
switch (arg) {
case "one":
console.log("One");
break;
case "two":
console.log("Two");
break;
case "three":
console.log("Three");
break;
default:
const exhaustiveCheck: never = arg;
}
console.log("No args action");
},
});
+8
View File
@@ -0,0 +1,8 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "noargs",
handler: async () => {
console.log("No args action");
},
});
+28
View File
@@ -0,0 +1,28 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "object",
parameters: [
{
name: "arg",
type: "object",
description: "The object argument to display.",
attributes: [
{
name: "x",
type: "string",
description: "The x attribute.",
},
{
name: "y",
type: "number",
description: "The y attribute.",
},
],
},
],
handler: async ({ arg }) => {
const x: string = arg.x;
const y: number = arg.y;
},
});
+20
View File
@@ -0,0 +1,20 @@
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "optional",
parameters: [
{
name: "arg",
type: "string",
description: "The optional argument to display.",
required: false,
},
],
handler: async ({ arg }) => {
let x: string = "y";
if (arg !== undefined) {
x = arg;
}
},
});
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
get_latest_versions() {
local result="" # Initialize an empty string to hold the results
for pkg in "$@"; do
# Encode the package name for use in a URL
encoded_pkg=$(echo "$pkg" | sed 's|/|%2F|g')
# Fetch the latest version of the package
latest_version=$(curl -s "https://registry.npmjs.org/$encoded_pkg" | jq -r '.["dist-tags"].latest')
# Check if the latest version was found
if [[ $latest_version != "null" && ! -z $latest_version ]]; then
# Append the package and version to the result string, separated by '@' and spaces between packages
result+="${pkg}@${latest_version} "
else
echo "Latest version for package ${pkg} could not be found." >&2
return 1 # Optionally return an error code if a package's latest version can't be found
fi
done
# Trim the trailing space and print the result
echo "${result% }"
}
get_latest_copilotkit_versions() {
get_latest_versions "@copilotkit/backend" "@copilotkit/react-core" "@copilotkit/react-textarea" "@copilotkit/react-ui" "@copilotkit/shared"
}
get_latest_prerelease_versions() {
local result="" # Initialize an empty string to hold the results
local tag_part="$1" # The specific part of the tag to match
# Shift the arguments so $@ contains the packages
shift
for pkg in "$@"; do
# Encode the package name for use in a URL
encoded_pkg=$(echo "$pkg" | sed 's|/|%2F|g')
# Fetch the list of all versions
versions=$(curl -s "https://registry.npmjs.org/$encoded_pkg" | jq -r '.versions | keys[]')
# Filter versions that match the tag part and get the last one
latest_prerelease_version=$(echo "$versions" | grep "$tag_part" | tail -n 1)
# Check if a version was found
if [[ ! -z $latest_prerelease_version ]]; then
# Append the package and version to the result string, separated by '@' and spaces between packages
result+="${pkg}@${latest_prerelease_version} "
else
echo "Latest pre-release version matching '$tag_part' for package ${pkg} could not be found." >&2
fi
done
# Trim the trailing space and print the result
echo "${result% }"
}
get_latest_copilotkit_prerelase_versions() {
get_latest_prerelease_versions $1 "@copilotkit/runtime" "@copilotkit/react-core" "@copilotkit/react-textarea" "@copilotkit/react-ui" "@copilotkit/shared"
}
use_local_packages() {
echo "Building local packages..."
pnpm -w freshbuild
echo "Done building local packages."
packages="file:$(pwd)/packages/runtime file:$(pwd)/packages/react-core file:$(pwd)/packages/react-textarea file:$(pwd)/packages/react-ui file:$(pwd)/packages/shared"
}
yarn_install_packages() {
local app_path="$1"
if [ -z "$packages" ]; then
use_local_packages;
fi
(cd "$app_path" && yarn add $packages)
info "Package manager: yarn"
info "Using CopilotKit packages: $packages"
}
npm_install_packages() {
local app_path="$1"
if [ -z "$packages" ]; then
use_local_packages;
fi
(cd "$app_path" && npm install $packages --save)
info "Package manager: npm"
info "Using CopilotKit packages: $packages"
}
+61
View File
@@ -0,0 +1,61 @@
source scripts/qa/lib/bash/qa.sh
source scripts/qa/lib/bash/packages.sh
prerelease_tag="$1"
packages=""
if [ -n "$prerelease_tag" ]; then
echo "Fetching pre-release CopilotKit packages..."
packages=$(get_latest_copilotkit_prerelase_versions "$prerelease_tag")
echo "Pre-release CopilotKit packages: $packages"
fi
if [ -z "$packages" ]; then
echo "No pre-release CopilotKit packages provided."
read -p "Enter package names separated by space or Enter to install local packages: " packages
fi
if [ -z "$packages" ]; then
echo "Installing local packages..."
else
echo "Installing packages: $packages"
fi
# only prompt for openai key if it is not set already
if [ -z "$OPENAI_API_KEY" ]; then
read -p "Enter OpenAI API key: " OPENAI_API_KEY
else
# Extract the first 5 characters of the API key
key_start=${OPENAI_API_KEY:0:5}
# Calculate the number of asterisks to print based on the key length
num_asterisks=$((${#OPENAI_API_KEY}-5))
asterisks=$(printf '%*s' "$num_asterisks" '' | tr ' ' '*')
echo "Using existing OPENAI_API_KEY: $key_start$asterisks"
fi
pid1=0
pid2=0
pid3=0
cleanup() {
if [ $pid1 -ne 0 ]; then
kill -9 $pid1 2>/dev/null || true
fi
if [ $pid2 -ne 0 ]; then
kill -9 $pid2 2>/dev/null || true
fi
if [ $pid3 -ne 0 ]; then
kill -9 $pid3 2>/dev/null || true
fi
killall next-server 2>/dev/null || true
}
# Trap Ctrl+C (INT signal) and exit
trap "echo 'Script interrupted.'; cleanup; exit" INT
trap "cleanup" EXIT
# Exit on any error
set -e
# record the current date + time
info "Test started at $(date)"
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Get the current date in YYYY-MM-DD format
current_date=$(date +%Y-%m-%d)
# Define the file path with the current date
file_path="/tmp/qa-${current_date}.txt"
prompt() {
local prompt="$1"
local user_input
while true; do
# Display the prompt to the user and read a single character input
read -p "$prompt (y/n): " -n 1 user_input
echo # Move to a new line
# Check the user input and append the prompt to the file with the appropriate emoji
if [[ $user_input == "y" ]]; then
echo -e "$prompt" >> "$file_path" # Green checkmark emoji
break
elif [[ $user_input == "n" ]]; then
echo -e "$prompt" >> "$file_path" # Red X emoji
break
else
echo "Invalid input. Please enter 'y' or 'n'."
fi
done
}
info() {
local info_msg="$1"
# Append the string to the file with the information emoji
echo -e "📢 $info_msg" >> "$file_path"
}
fail() {
local fail_msg="$1"
# Append the string to the file with the information emoji
echo -e "$fail_msg" >> "$file_path"
}
succeed() {
local success_msg="$1"
# Append the string to the file with the information emoji
echo -e "$success_msg" >> "$file_path"
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotKitCSSProperties, CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
render: (props) => {
return (
<div style={{ backgroundColor: "black", color: "white" }}>
<div>Status: {props.status}</div>
<div>Message: {props.args.message}</div>
</div>
);
},
},
[],
);
return (
<>
<div>{message}</div>
</>
);
}
export default function Home() {
return (
<div
style={
{
height: `100vh`,
"--copilot-kit-primary-color": "red",
} as CopilotKitCSSProperties
}
>
<CopilotKit url="/api/copilotkit/openai">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
icons={{
sendIcon: "📩",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/runtime";
export async function POST(req: Request): Promise<Response> {
const copilotKit = new CopilotRuntime();
return copilotKit.response(req, new OpenAIAdapter({}));
}
+5
View File
@@ -0,0 +1,5 @@
{
"projects": {
"default": "copilotkit-test-12345"
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"functions": [
{
"source": "functions",
"codebase": "default",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log"
],
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
]
}
],
"emulators": {
"functions": {
"port": 5001
},
"ui": {
"enabled": true
},
"singleProjectMode": true
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Import function triggers from their respective submodules:
*
* import {onCall} from "firebase-functions/v2/https";
* import {onDocumentWritten} from "firebase-functions/v2/firestore";
*
* See a full list of supported triggers at https://firebase.google.com/docs/functions
*/
import { onRequest } from "firebase-functions/v2/https";
// import * as logger from "firebase-functions/logger";
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/runtime";
// Start writing functions
// https://firebase.google.com/docs/functions/typescript
export const copilotKit = onRequest((request, response) => {
const copilotKit = new CopilotRuntime();
copilotKit.streamHttpServerResponse(request, response, new OpenAIAdapter({}));
});
+31
View File
@@ -0,0 +1,31 @@
{
"name": "functions",
"private": true,
"main": "lib/index.js",
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"dependencies": {
"@copilotkit/backend": "^0.7.0-mme-firebase-fixes.0",
"@copilotkit/react-core": "^0.23.0-mme-firebase-fixes.0",
"@copilotkit/react-textarea": "^0.33.0-mme-firebase-fixes.0",
"@copilotkit/react-ui": "^0.20.0-mme-firebase-fixes.0",
"@copilotkit/shared": "^0.7.0-mme-firebase-fixes.0",
"firebase-admin": "^12.0.0",
"firebase-functions": "^4.3.1"
},
"devDependencies": {
"firebase-functions-test": "^3.1.0",
"firebase-tools": "^13.6.0",
"typescript": "^5.4.3"
},
"engines": {
"node": "18"
}
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<div className="h-screen w-full flex items-center justify-center text-2xl">
{message}
</div>
);
}
export default function Home() {
return (
<CopilotKit url="http://127.0.0.1:5001/copilotkit-test-12345/us-central1/copilotKit">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
clickOutsideToClose={false}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "commonjs",
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017"
},
"compileOnSave": true,
"include": ["src"]
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
render: (props) => {
return (
<div style={{ backgroundColor: "black", color: "white" }}>
<div>Status: {props.status}</div>
<div>Message: {props.args.message}</div>
</div>
);
},
},
[],
);
return (
<>
<div>{message}</div>
{/* <CopilotTextarea
value={text}
onChange={(e) => setText(e.target.value)}
autosuggestionsConfig={{
textareaPurpose: "an outline of a presentation about elephants",
chatApiConfigs: {},
}}
/> */}
</>
);
}
export default function Home() {
return (
<CopilotKit url="/api/copilotkit/langchain">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+43
View File
@@ -0,0 +1,43 @@
import {
CopilotRuntime,
LangChainAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { ChatOpenAI } from "@langchain/openai";
const runtime = new CopilotRuntime({
actions: [
{
name: "sayHello",
description: "Says hello to someone.",
parameters: [
{
name: "arg",
type: "string",
description: "The name of the person to say hello to.",
required: true,
},
],
handler: async ({ arg }) => {
console.log("Hello from the server", arg, "!");
},
},
],
});
const serviceAdapter = new LangChainAdapter({
chainFn: async ({ messages, tools }) => {
const model = new ChatOpenAI({ modelName: "gpt-4-1106-preview" }).bind(
tools as any,
);
return model.stream(messages);
},
});
export const { GET, POST, OPTIONS } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit/langchain",
debug: true,
}) as any;
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python
"""Example LangChain server exposes multiple runnables (LLMs in this case)."""
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from langchain.chat_models import ChatOpenAI
from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings
from langchain.agents import AgentExecutor, tool
from langchain.tools.render import format_tool_to_openai_function
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.agents.output_parsers import OpenAIFunctionsAgentOutputParser
from langchain.pydantic_v1 import BaseModel
from typing import Any
from langchain.agents.format_scratchpad import format_to_openai_functions
from langserve import add_routes
app = FastAPI(
title="LangChain Server",
version="1.0",
description="Spin up a simple api server using Langchain's Runnable interfaces",
)
# ChatOpenAI
# ----------
# We probably can't support ChatOpenAI...
# see input schema: http://localhost:8000/openai/input_schema
# also playground: http://localhost:8000/openai/playground/
# it looks tricky to support this in a generic way
add_routes(
app,
ChatOpenAI(),
path="/openai",
)
# Retriever
# ---------
# receives a single input VectorStoreRetrieverInput (type string)
# Input Schema: {"title":"VectorStoreRetrieverInput","type":"string"}
# according to the client docs, it can be called like this:
# - requests.post("http://localhost:8000/invoke", json={"input": "tree"})
# - remote_runnable.invoke("tree")
vectorstore = FAISS.from_texts(
["cats like fish", "dogs like sticks"], embedding=OpenAIEmbeddings()
)
retriever = vectorstore.as_retriever()
add_routes(
app,
retriever,
path="/retriever",
)
# Agent
# -----
# Input Schema: {"title":"Input","type":"object","properties":{"input":{"title":"Input","type":"string"}},"required":["input"]}
# - requests.post("http://localhost:8000/invoke", json={"input": {"input": "what does eugene think of cats?"}})
# - remote_runnable.invoke({"input": "what does eugene think of cats?"})
@tool
def get_eugene_thoughts(query: str) -> list:
"""Returns Eugene's thoughts on a topic."""
return retriever.get_relevant_documents(query)
tools = [get_eugene_thoughts]
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0, streaming=True)
llm_with_tools = llm.bind(functions=[format_tool_to_openai_function(t) for t in tools])
prompt = ChatPromptTemplate.from_messages(
[
("system", "You are a helpful assistant."),
("user", "{input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
]
)
agent = (
{
"input": lambda x: x["input"],
"agent_scratchpad": lambda x: format_to_openai_functions(
x["intermediate_steps"]
),
}
| prompt
| llm_with_tools
| OpenAIFunctionsAgentOutputParser()
)
agent_executor = AgentExecutor(graph=agent, tools=tools)
class Input(BaseModel):
input: str
class Output(BaseModel):
output: Any
add_routes(
app,
agent_executor.with_types(input_type=Input, output_type=Output).with_config(
{"run_name": "agent"}
),
path="/agent",
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="localhost", port=8000)
+74
View File
@@ -0,0 +1,74 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
render: (props) => {
return (
<div style={{ backgroundColor: "black", color: "white" }}>
<div>Status: {props.status}</div>
<div>Message: {props.args.message}</div>
</div>
);
},
},
[],
);
return (
<>
<div>{message}</div>
{/* <CopilotTextarea
value={text}
onChange={(e) => setText(e.target.value)}
autosuggestionsConfig={{
textareaPurpose: "an outline of a presentation about elephants",
chatApiConfigs: {},
}}
/> */}
</>
);
}
export default function Home() {
return (
<CopilotKit url="/api/copilotkit/openai">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+87
View File
@@ -0,0 +1,87 @@
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
const runtime = new CopilotRuntime({
actions: [
{
name: "sayHello",
description: "Says hello to someone.",
parameters: [
{
name: "name",
type: "string",
description: "The name of the person to say hello to.",
required: true,
},
],
handler: async ({ name }) => {
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
"The user tells you their name. Say hello to the person in the most " +
" ridiculous way, roasting their name.",
],
["user", "My name is {name}"],
]);
const chain = prompt.pipe(new ChatOpenAI());
return chain.invoke({
name: name,
});
},
},
{
name: "sayGoodbye",
description: "Says goodbye to someone.",
parameters: [
{
name: "name",
type: "string",
description: "The name of the person to say goodbye to.",
required: true,
},
],
handler: async ({ name }) => {
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
"The user tells you their name. Say goodbye to the person in the most " +
" ridiculous way, roasting their name.",
],
["user", "My name is {name}"],
]);
const chain = prompt.pipe(new ChatOpenAI());
return chain.stream({
name: name,
});
},
},
],
langserve: [
{
chainUrl: "http://localhost:8000/retriever",
name: "askAboutAnimals",
description:
"Always call this function if the users asks about a certain animal.",
},
{
chainUrl: "http://localhost:8000/agent",
name: "askAboutEugeneThoughts",
description:
"Always call this function if the users asks about Eugene's thoughts on a certain topic.",
},
],
});
const serviceAdapter = new OpenAIAdapter();
export const { GET, POST, OPTIONS } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit/openai",
debug: true,
}) as any;
@@ -0,0 +1,8 @@
python-dotenv
fastapi
langserve
langchain
langchain-cli
langchain-community
langchain-core
langchain-openai
+27
View File
@@ -0,0 +1,27 @@
/**
* @filePath pages/api/copilotkit.ts
*/
import { NextApiRequest, NextApiResponse } from "next";
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSPagesRouterEndpoint,
} from "@copilotkit/runtime";
import OpenAI from "openai";
const openai = new OpenAI();
const serviceAdapter = new OpenAIAdapter({ openai });
const runtime = new CopilotRuntime();
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const handleRequest = copilotRuntimeNextJSPagesRouterEndpoint({
endpoint: "/api/copilotkit",
runtime,
serviceAdapter,
});
return await handleRequest(req, res);
};
export default handler;
+64
View File
@@ -0,0 +1,64 @@
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
render: (props) => {
return (
<div style={{ backgroundColor: "black", color: "white" }}>
<div>Status: {props.status}</div>
<div>Message: {props.args.message}</div>
</div>
);
},
},
[],
);
return (
<>
<div>{message}</div>
</>
);
}
export default function Home() {
return (
<CopilotKit runtimeUrl="/api/copilotkit">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
render: (props) => {
return (
<div style={{ backgroundColor: "black", color: "white" }}>
<div>Status: {props.status}</div>
<div>Message: {props.args.message}</div>
</div>
);
},
},
[],
);
return (
<>
<div>{message}</div>
<CopilotTextarea
value={text}
onChange={(e) => setText(e.target.value)}
autosuggestionsConfig={{
textareaPurpose: "an outline of a presentation about elephants",
chatApiConfigs: {},
}}
/>
</>
);
}
export default function Home() {
return (
<CopilotKit url="/api/copilotkit/openai">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* @filePath app/copilotkit/route.ts
*/
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { NextRequest } from "next/server";
import OpenAI from "openai";
const openai = new OpenAI();
const serviceAdapter = new OpenAIAdapter({ openai });
const runtime = new CopilotRuntime();
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: req.nextUrl.pathname,
});
return handleRequest(req);
};
+29
View File
@@ -0,0 +1,29 @@
/**
* @filePath server.ts
*/
import express from "express";
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNodeHttpEndpoint,
} from "@copilotkit/runtime";
import OpenAI from "openai";
const openai = new OpenAI();
const serviceAdapter = new OpenAIAdapter({ openai });
const runtime = new CopilotRuntime();
const copilotRuntime = copilotRuntimeNodeHttpEndpoint({
endpoint: "/copilotkit",
runtime,
serviceAdapter,
});
const app = express();
app.use("/copilotkit", copilotRuntime);
app.listen(4000, () => {
console.log("Listening at http://localhost:4000/copilotkit");
});
+58
View File
@@ -0,0 +1,58 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
</>
);
}
export default function Home() {
return (
<CopilotKit url="http://localhost:4000">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* @filePath server.ts
*/
import { createServer } from "node:http";
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNodeHttpEndpoint,
} from "@copilotkit/runtime";
import OpenAI from "openai";
const openai = new OpenAI();
const serviceAdapter = new OpenAIAdapter({ openai });
const runtime = new CopilotRuntime();
const copilotRuntime = copilotRuntimeNodeHttpEndpoint({
endpoint: "/copilotkit",
runtime,
serviceAdapter,
});
const server = createServer((req, res) => {
return copilotRuntime(req, res);
});
server.listen(4000, () => {
console.log("Listening at http://localhost:4000/copilotkit");
});
+82
View File
@@ -0,0 +1,82 @@
import type { MetaFunction } from "@remix-run/node";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
render: (props) => {
return (
<div style={{ backgroundColor: "black", color: "white" }}>
<div>Status: {props.status}</div>
<div>Message: {props.args.message}</div>
</div>
);
},
},
[],
);
return (
<>
<div>{message}</div>
<CopilotTextarea
value={text}
onChange={(e) => setText(e.target.value)}
autosuggestionsConfig={{
textareaPurpose: "an outline of a presentation about elephants",
chatApiConfigs: {},
}}
/>
</>
);
}
export default function Index() {
return (
<CopilotKit runtimeUrl="/copilotkit">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/runtime";
import type { ActionFunctionArgs } from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const copilotKit = new CopilotRuntime();
return copilotKit.response(request, new OpenAIAdapter({}));
}
@@ -0,0 +1,58 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
</>
);
}
export default function Home() {
return (
<CopilotKit url="http://localhost:4000" properties={{ userId: "xyz" }}>
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
@@ -0,0 +1,34 @@
import express from "express";
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNodeHttpEndpoint,
} from "@copilotkit/runtime";
const port = 4000;
var HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, PATCH, DELETE",
"Access-Control-Allow-Headers": "X-Requested-With,content-type",
};
const handler = copilotRuntimeNodeHttpEndpoint({
endpoint: "/",
runtime: new CopilotRuntime(),
serviceAdapter: new OpenAIAdapter(),
});
const app = express();
app.use("/", (req, res, next) => {
if (req.method === "OPTIONS") {
res.writeHead(200, HEADERS);
res.end();
return;
}
return handler(req, res, next);
});
app.listen(port, () => {
console.log("Listening at http://localhost:4000/copilotkit");
});
@@ -0,0 +1,58 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
</>
);
}
export default function Home() {
return (
<CopilotKit url="http://localhost:4000">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
@@ -0,0 +1,25 @@
import express from "express";
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/backend";
const port = 4000;
var HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, PATCH, DELETE",
"Access-Control-Allow-Headers": "X-Requested-With,content-type",
};
const app = express();
app.use("/", (req, res, next) => {
if (req.method === "OPTIONS") {
res.writeHead(200, HEADERS);
res.end();
return;
}
var copilotKit = new CopilotRuntime();
copilotKit.streamHttpServerResponse(req, res, new OpenAIAdapter(), HEADERS);
});
app.listen(port, () => {
console.log("Listening at http://localhost:4000/copilotkit");
});
@@ -0,0 +1,69 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
<CopilotTextarea
value={text}
onChange={(e) => setText(e.target.value)}
autosuggestionsConfig={{
textareaPurpose: "an outline of a presentation about elephants",
chatApiConfigs: {},
}}
/>
</>
);
}
export default function Home() {
return (
<CopilotKit
runtimeUrl="/api/copilotkit/openai"
properties={{ userid: "abc_123" }}
>
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
@@ -0,0 +1,36 @@
import { NextRequest } from "next/server";
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
const runtime = new CopilotRuntime({
actions: [
{
name: "sayHello",
description: "say hello so someone by roasting their name",
parameters: [
{
name: "roast",
description: "A sentence or two roasting the name of the person",
type: "string",
required: true,
},
],
handler: ({ roast }) => {
console.log(roast);
},
},
],
});
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter: new OpenAIAdapter(),
endpoint: req.nextUrl.pathname,
});
return handleRequest(req);
};
@@ -0,0 +1,66 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
<CopilotTextarea
value={text}
onChange={(e) => setText(e.target.value)}
autosuggestionsConfig={{
textareaPurpose: "an outline of a presentation about elephants",
chatApiConfigs: {},
}}
/>
</>
);
}
export default function Home() {
return (
<CopilotKit runtimeUrl="/api/copilotkit/openai">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
@@ -0,0 +1,26 @@
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/backend";
export async function POST(req: Request): Promise<Response> {
const copilotKit = new CopilotRuntime({
actions: [
{
name: "research",
description:
"Call this function to conduct research on a certain topic. Respect other notes about when to call this function",
parameters: [
{
name: "topic",
type: "string",
description: "The topic to research. 5 characters or longer.",
required: true,
},
],
handler: async ({ topic }) => {
console.log("Researching topic: ", topic);
return "The secret is xyz";
},
},
],
});
return copilotKit.response(req, new OpenAIAdapter({}));
}
@@ -0,0 +1,69 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
<CopilotTextarea
value={text}
onChange={(e) => setText(e.target.value)}
autosuggestionsConfig={{
textareaPurpose: "an outline of a presentation about elephants",
chatApiConfigs: {},
}}
/>
</>
);
}
export default function Home() {
return (
<CopilotKit
runtimeUrl="/api/copilotkit/openai"
properties={{ userid: "abc_123" }}
>
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
@@ -0,0 +1,48 @@
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSPagesRouterEndpoint,
} from "@copilotkit/runtime";
import { NextApiRequest, NextApiResponse } from "next";
const serviceAdapter = new OpenAIAdapter();
const runtime = new CopilotRuntime({
actions: [
{
name: "sayHello",
description: "say hello so someone by roasting their name",
parameters: [
{
name: "roast",
description: "A sentence or two roasting the name of the person",
type: "string",
required: true,
},
],
handler: ({ roast }) => {
console.log(roast);
return "The person has been roasted.";
},
},
],
});
// This is required for file upload to work
export const config = {
api: {
bodyParser: false,
},
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const handleRequest = copilotRuntimeNextJSPagesRouterEndpoint({
endpoint: "/api/copilotkit",
runtime,
serviceAdapter,
});
return await handleRequest(req, res);
};
export default handler;
@@ -0,0 +1,66 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
<CopilotTextarea
value={text}
onChange={(e) => setText(e.target.value)}
autosuggestionsConfig={{
textareaPurpose: "an outline of a presentation about elephants",
chatApiConfigs: {},
}}
/>
</>
);
}
export default function Home() {
return (
<CopilotKit runtimeUrl="/api/copilotkit/openai">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
@@ -0,0 +1,12 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/backend";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const copilotKit = new CopilotRuntime({});
copilotKit.streamHttpServerResponse(
req,
res,
new OpenAIAdapter({ model: "gpt-4o" }),
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
</>
);
}
export default function Home() {
return (
<CopilotKit url="http://localhost:4000" properties={{ userId: "xyz" }}>
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+33
View File
@@ -0,0 +1,33 @@
import * as http from "http";
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNodeHttpEndpoint,
} from "@copilotkit/runtime";
const port = 4000;
var HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, PATCH, DELETE",
"Access-Control-Allow-Headers": "X-Requested-With,content-type",
};
const handler = copilotRuntimeNodeHttpEndpoint({
endpoint: "/",
runtime: new CopilotRuntime(),
serviceAdapter: new OpenAIAdapter(),
});
var server = http.createServer(function (req, res) {
// Respond to OPTIONS (preflight) request
if (req.method === "OPTIONS") {
res.writeHead(200, HEADERS);
res.end();
return;
}
return handler(req, res);
});
server.listen(port, function () {
console.log(`Server running at http://localhost:${port}`);
});
+58
View File
@@ -0,0 +1,58 @@
"use client";
import {
CopilotKit,
useCopilotAction,
useCopilotReadable,
} from "@copilotkit/react-core";
import { CopilotTextarea } from "@copilotkit/react-textarea";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { useState } from "react";
import "@copilotkit/react-textarea/styles.css";
import "@copilotkit/react-ui/styles.css";
function InsideHome() {
const [message, setMessage] = useState("Hello World!");
const [text, setText] = useState("");
useCopilotReadable({
description: "This is the current message",
value: message,
});
useCopilotAction(
{
name: "displayMessage",
description: "Display a message.",
parameters: [
{
name: "message",
type: "string",
description: "The message to display.",
required: true,
},
],
handler: async ({ message }) => {
setMessage(message);
},
},
[],
);
return (
<>
<div>{message}</div>
</>
);
}
export default function Home() {
return (
<CopilotKit url="http://localhost:4000">
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Presentation Copilot",
initial: "Hi you! 👋 I can give you a presentation on any topic.",
}}
>
<InsideHome />
</CopilotSidebar>
</CopilotKit>
);
}
+22
View File
@@ -0,0 +1,22 @@
import * as http from "http";
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/backend";
const port = 4000;
var HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, PATCH, DELETE",
"Access-Control-Allow-Headers": "X-Requested-With,content-type",
};
var server = http.createServer(function (req, res) {
// Respond to OPTIONS (preflight) request
if (req.method === "OPTIONS") {
res.writeHead(200, HEADERS);
res.end();
return;
}
var copilotKit = new CopilotRuntime();
copilotKit.streamHttpServerResponse(req, res, new OpenAIAdapter(), HEADERS);
});
server.listen(port, function () {
console.log(`Server running at http://localhost:${port}`);
});
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
NEXT_PAGES_APP_PATH="/tmp/test-next-pages-app"
echo "Creating next app in $NEXT_PAGES_APP_PATH"
echo ""
# Remove prev project and run create-next-app
rm -rf $NEXT_PAGES_APP_PATH
npx create-next-app $NEXT_PAGES_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --no-app --import-alias="@/*"
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $NEXT_PAGES_APP_PATH/.env
npm_install_packages $NEXT_PAGES_APP_PATH
cp scripts/qa/lib/next-pages/index.tsx $NEXT_PAGES_APP_PATH/src/pages/index.tsx
# Open VSCode
code $NEXT_PAGES_APP_PATH
prompt "Open index.tsx. Is it without errors in VSCode?"
cp scripts/qa/lib/next-pages/copilotkit.ts $NEXT_PAGES_APP_PATH/src/pages/api/copilotkit.ts
prompt "Open copilotkit.ts. Is it without errors in VSCode?"
# Temporarily disable -e
set +e
pushd $NEXT_PAGES_APP_PATH
npm run build
exit_status=$?
if [ $exit_status -eq 0 ]; then
succeed "$pkg_manager build succeeded."
else
fail "$pkg_manager build failed with status $exit_status."
exit 1
fi
# Re-enable -e
set -e
npm run dev > /dev/null 2>&1 &
pid1=$!
popd
prompt "Open http://localhost:3000. Is the page without errors?"
prompt "Chat to check if regular text and message history works (2x)?"
prompt "Ask the copilot to change the message. Is the message changed?"
prompt "Ask the copilot to change the message again. Is the message changed?"
prompt "Ask for a long message. Does the custom render work & stream?"
prompt "Does it provide the current message when asked?"
prompt "Test the keyboard shortcut cmd-\\ to open close the sidebar. Does it work?"
prompt "Does the text input autofocus when the sidebar is opened?"
prompt "In the text area, start a text about elephants. Does the autosuggestions work?"
killall next-server;
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Read the package manager
read -p "Enter package manager (yarn/npm): " pkg_manager
if [ -z "$pkg_manager" ]; then
pkg_manager="npm"
fi
if [ "$pkg_manager" != "yarn" ] && [ "$pkg_manager" != "npm" ]; then
echo "Unsupported package manager. Exiting."
exit 1
fi
# Create the next app in /tmp
NEXT_APP_PATH="/tmp/test-next-app"
echo "Creating next app in $NEXT_APP_PATH"
echo ""
# Remove prev project and run create-next-app
rm -rf $NEXT_APP_PATH
if [ "$pkg_manager" = "yarn" ]; then
(cd /tmp && yarn create next-app $NEXT_APP_PATH --ts --eslint --no-tailwind --src-dir --app --import-alias="@/*")
elif [ "$pkg_manager" = "npm" ]; then
npx create-next-app $NEXT_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
fi
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $NEXT_APP_PATH/.env
if [ "$pkg_manager" = "yarn" ]; then
yarn_install_packages $NEXT_APP_PATH
elif [ "$pkg_manager" = "npm" ]; then
npm_install_packages $NEXT_APP_PATH
fi
cp scripts/qa/lib/next/page.tsx $NEXT_APP_PATH/src/app/page.tsx
# Open VSCode
code $NEXT_APP_PATH
prompt "Open page.tsx. Is it without errors in VSCode?"
mkdir -p $NEXT_APP_PATH/src/app/api/copilotkit/openai/
cp scripts/qa/lib/next/route.ts $NEXT_APP_PATH/src/app/api/copilotkit/openai/route.ts
prompt "Open route.ts. Is it without errors in VSCode?"
# Temporarily disable -e
set +e
pushd $NEXT_APP_PATH
if [ "$pkg_manager" = "yarn" ]; then
yarn build
elif [ "$pkg_manager" = "npm" ]; then
npm run build
fi
exit_status=$?
if [ $exit_status -eq 0 ]; then
succeed "$pkg_manager build succeeded."
else
fail "$pkg_manager build failed with status $exit_status."
exit 1
fi
# Re-enable -e
set -e
if [ "$pkg_manager" = "yarn" ]; then
yarn dev > /dev/null 2>&1 &
elif [ "$pkg_manager" = "npm" ]; then
npm run dev > /dev/null 2>&1 &
fi
pid1=$!
popd
prompt "Open http://localhost:3000. Is the page without errors?"
prompt "Chat to check if regular text and message history works (2x)?"
prompt "Ask the copilot to change the message. Is the message changed?"
prompt "Ask the copilot to change the message again. Is the message changed?"
prompt "Ask for a long message. Does the custom render work & stream?"
prompt "Does it provide the current message when asked?"
prompt "Test the keyboard shortcut cmd-\\ to open close the sidebar. Does it work?"
prompt "Does the text input autofocus when the sidebar is opened?"
prompt "In the text area, start a text about elephants. Do the autosuggestions work?"
prompt "Verify that the text area also completes text in the middle of the sentence."
killall next-server;
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
NODE_APP_PATH="/tmp/test-node-app"
rm -rf $NODE_APP_PATH
echo "Creating node app in $NODE_APP_PATH"
# prepare the python app
mkdir -p $NODE_APP_PATH
npx create-next-app $NODE_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
npm_install_packages $NODE_APP_PATH
(cd $NODE_APP_PATH && npm install -D typescript ts-node @types/node)
cp scripts/qa/lib/node/page.tsx $NODE_APP_PATH/src/app/page.tsx
cp scripts/qa/lib/node/server.ts $NODE_APP_PATH/server.ts
# Temporarily disable -e
set +e
pushd $NODE_APP_PATH
npm run build
exit_status=$?
if [ $exit_status -eq 0 ]; then
succeed "$pkg_manager build succeeded."
else
fail "$pkg_manager build failed with status $exit_status."
exit 1
fi
# Re-enable -e
set -e
# Start next server
npm run dev > /dev/null 2>&1 &
pid1=$!
# Start node server
node npx ts-node server.ts > /dev/null 2>&1 &
pid2=$!
popd
prompt "Open http://localhost:3000. Is the page without errors?"
prompt "Chat with it. Does it work?"
prompt "Ask it to change the message. Does it work?"
killall next-server;
cleanup;
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0;
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
REMIX_APP_PATH="/tmp/test-remix-app"
echo "Creating remix app in $REMIX_APP_PATH"
echo ""
# Remove prev project and run create-next-app
npx create-remix@latest $REMIX_APP_PATH -y
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $REMIX_APP_PATH/.env
npm_install_packages $REMIX_APP_PATH
cp scripts/qa/lib/remix/_index.tsx $REMIX_APP_PATH/app/routes/_index.tsx
# Open VSCode
code $REMIX_APP_PATH
prompt "Open _index.tsx. Is it without errors in VSCode?"
cp scripts/qa/lib/remix/copilotkit.tsx $REMIX_APP_PATH/app/routes/copilotkit.tsx
prompt "Open copilotkit.tsx. Is it without errors in VSCode?"
# Temporarily disable -e
set +e
pushd $REMIX_APP_PATH
npm run build
exit_status=$?
if [ $exit_status -eq 0 ]; then
succeed "$pkg_manager build succeeded."
else
fail "$pkg_manager build failed with status $exit_status."
exit 1
fi
# Re-enable -e
set -e
npm run dev > /dev/null 2>&1 &
pid1=$!
popd
prompt "Open http://localhost:5173. Is the page without errors?"
prompt "Chat to check if regular text and message history works (2x)?"
prompt "Ask the copilot to change the message. Is the message changed?"
prompt "Ask the copilot to change the message again. Is the message changed?"
prompt "Ask for a long message. Does the custom render work & stream?"
prompt "Does it provide the current message when asked?"
prompt "Test the keyboard shortcut cmd-\\ to open close the sidebar. Does it work?"
prompt "Does the text input autofocus when the sidebar is opened?"
prompt "In the text area, start a text about elephants. Does the autosuggestions work?"
killall next-server;
succeed "Test completed successfully."
echo "===================="
echo "Test completed at $(date)"
echo "===================="
exit 0
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
UPGRADE_EXPRESS_PATH="/tmp/upgrade-express"
rm -rf $UPGRADE_EXPRESS_PATH
echo "Creating express app for testing upgrade in $UPGRADE_EXPRESS_PATH"
mkdir -p $UPGRADE_EXPRESS_PATH
npx create-next-app $UPGRADE_EXPRESS_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
echo "Fetching released versions of CopilotKit packages..."
released_packages=$(get_latest_copilotkit_versions)
echo "Latest released versions: $released_packages"
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $UPGRADE_EXPRESS_PATH/.env
(cd $UPGRADE_EXPRESS_PATH && npm install $released_packages --save)
(cd $UPGRADE_EXPRESS_PATH && npm install express)
(cd $UPGRADE_EXPRESS_PATH && npm i --save-dev @types/express)
echo "Using released CopilotKit packages: $released_packages"
echo "Testing upgrading to pre-release versions: $packages"
(cd $UPGRADE_EXPRESS_PATH && npm install -D typescript ts-node @types/node)
cp scripts/qa/lib/upgrade-express/old/page.tsx $UPGRADE_EXPRESS_PATH/src/app/page.tsx
cp scripts/qa/lib/upgrade-express/old/server.ts $UPGRADE_EXPRESS_PATH/server.ts
jq '. * {
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
}
}' $UPGRADE_EXPRESS_PATH/tsconfig.json > $UPGRADE_EXPRESS_PATH/temp.json && mv $UPGRADE_EXPRESS_PATH/temp.json $UPGRADE_EXPRESS_PATH/tsconfig.json
prompt "Check server.ts and page.tsx. Are they without errors in VSCode?"
echo "Upgrading packages"
(cd $UPGRADE_EXPRESS_PATH && npm install $packages --save)
cp scripts/qa/lib/upgrade-express/new/page.tsx $UPGRADE_EXPRESS_PATH/src/app/page.tsx
cp scripts/qa/lib/upgrade-express/new/server.ts $UPGRADE_EXPRESS_PATH/server.ts
prompt "Check server.ts and page.tsx again. Are they without errors in VSCode?"
echo "now run ts-node server.ts to test"
cleanup;
exit 0;
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
UPGRADE_NEXT_APP_ROUTER_PATH="/tmp/upgrade-next-app-router"
echo "Creating next app for testing upgrading next app router in $UPGRADE_NEXT_APP_ROUTER_PATH"
echo ""
# Remove prev project and run create-next-app
rm -rf $UPGRADE_NEXT_APP_ROUTER_PATH
npx create-next-app $UPGRADE_NEXT_APP_ROUTER_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
echo "Fetching released versions of CopilotKit packages..."
released_packages=$(get_latest_copilotkit_versions)
echo "Latest released versions: $released_packages"
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $UPGRADE_NEXT_APP_ROUTER_PATH/.env
(cd $UPGRADE_NEXT_APP_ROUTER_PATH && npm install $released_packages --save)
echo "Using released CopilotKit packages: $released_packages"
echo "Testing upgrading to pre-release versions: $packages"
cp scripts/qa/lib/upgrade-next-app/old/page.tsx $UPGRADE_NEXT_APP_ROUTER_PATH/src/app/page.tsx
mkdir -p $UPGRADE_NEXT_APP_ROUTER_PATH/src/app/api/copilotkit/openai/
cp scripts/qa/lib/upgrade-next-app/old/route.ts $UPGRADE_NEXT_APP_ROUTER_PATH/src/app/api/copilotkit/openai/route.ts
# Open VSCode
code $UPGRADE_NEXT_APP_ROUTER_PATH
prompt "Check route.ts and page.tsx. Are they without errors in VSCode?"
echo "Upgrading packages"
(cd $UPGRADE_NEXT_APP_ROUTER_PATH && npm install $packages --save)
cp scripts/qa/lib/upgrade-next-app/new/page.tsx $UPGRADE_NEXT_APP_ROUTER_PATH/src/app/page.tsx
cp scripts/qa/lib/upgrade-next-app/new/route.ts $UPGRADE_NEXT_APP_ROUTER_PATH/src/app/api/copilotkit/openai/route.ts
prompt "Check route.ts and page.tsx again. Are they without errors in VSCode?"
cleanup;
exit 0
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
UPGRADE_NEXT_PAGES_ROUTER_PATH="/tmp/upgrade-next-pages-router"
echo "Creating next app for testing upgrading next pages router in $UPGRADE_NEXT_PAGES_ROUTER_PATH"
echo ""
# Remove prev project and run create-next-app
rm -rf $UPGRADE_NEXT_PAGES_ROUTER_PATH
npx create-next-app $UPGRADE_NEXT_PAGES_ROUTER_PATH --ts --eslint --use-npm --no-tailwind --src-dir --no-app --import-alias="@/*"
echo "Fetching released versions of CopilotKit packages..."
released_packages=$(get_latest_copilotkit_versions)
echo "Latest released versions: $released_packages"
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $UPGRADE_NEXT_PAGES_ROUTER_PATH/.env
(cd $UPGRADE_NEXT_PAGES_ROUTER_PATH && npm install $released_packages --save)
echo "Using released CopilotKit packages: $released_packages"
echo "Testing upgrading to pre-release versions: $packages"
cp scripts/qa/lib/upgrade-next-pages/old/page.tsx $UPGRADE_NEXT_PAGES_ROUTER_PATH/src/pages/page.tsx
mkdir -p $UPGRADE_NEXT_PAGES_ROUTER_PATH/src/pages/api/copilotkit/openai/
cp scripts/qa/lib/upgrade-next-pages/old/route.ts $UPGRADE_NEXT_PAGES_ROUTER_PATH/src/pages/api/copilotkit/openai/route.ts
# Open VSCode
code $UPGRADE_NEXT_PAGES_ROUTER_PATH
prompt "Check route.ts and page.tsx. Are they without errors in VSCode?"
echo "Upgrading packages"
(cd $UPGRADE_NEXT_PAGES_ROUTER_PATH && npm install $packages --save)
cp scripts/qa/lib/upgrade-next-pages/new/page.tsx $UPGRADE_NEXT_PAGES_ROUTER_PATH/src/pages/page.tsx
cp scripts/qa/lib/upgrade-next-pages/new/route.ts $UPGRADE_NEXT_PAGES_ROUTER_PATH/src/pages/api/copilotkit/openai/route.ts
prompt "Check route.ts and page.tsx again. Are they without errors in VSCode?"
cleanup;
exit 0
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
source scripts/qa/lib/bash/prelude.sh
# Create the next app in /tmp
UPGRADE_NODE_APP_PATH="/tmp/upgrade-node"
rm -rf $UPGRADE_NODE_APP_PATH
echo "Creating node app for testing upgrade in $UPGRADE_NODE_APP_PATH"
mkdir -p $UPGRADE_NODE_APP_PATH
npx create-next-app $UPGRADE_NODE_APP_PATH --ts --eslint --use-npm --no-tailwind --src-dir --app --import-alias="@/*"
echo "Fetching released versions of CopilotKit packages..."
released_packages=$(get_latest_copilotkit_versions)
echo "Latest released versions: $released_packages"
# write to .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > $UPGRADE_NODE_APP_PATH/.env
(cd $UPGRADE_NODE_APP_PATH && npm install $released_packages --save)
echo "Using released CopilotKit packages: $released_packages"
echo "Testing upgrading to pre-release versions: $packages"
(cd $UPGRADE_NODE_APP_PATH && npm install -D typescript ts-node @types/node)
cp scripts/qa/lib/upgrade-node/old/page.tsx $UPGRADE_NODE_APP_PATH/src/app/page.tsx
cp scripts/qa/lib/upgrade-node/old/server.ts $UPGRADE_NODE_APP_PATH/server.ts
jq '. * {
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
}
}' $UPGRADE_NODE_APP_PATH/tsconfig.json > $UPGRADE_NODE_APP_PATH/temp.json && mv $UPGRADE_NODE_APP_PATH/temp.json $UPGRADE_NODE_APP_PATH/tsconfig.json
prompt "Check server.ts and page.tsx. Are they without errors in VSCode?"
echo "Upgrading packages"
(cd $UPGRADE_NODE_APP_PATH && npm install $packages --save)
cp scripts/qa/lib/upgrade-node/new/page.tsx $UPGRADE_NODE_APP_PATH/src/app/page.tsx
cp scripts/qa/lib/upgrade-node/new/server.ts $UPGRADE_NODE_APP_PATH/server.ts
prompt "Check server.ts and page.tsx again. Are they without errors in VSCode?"
echo "now run ts-node server.ts to test"
cleanup;
exit 0;
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# Red-green test for detect-py-version-changes.sh using a local fixture HTTP
# server. No network access. Requires python3 >= 3.11 (tomllib).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="${HERE}/../detect-py-version-changes.sh"
TMP="$(mktemp -d)"
SRV_PID=""
STDERR_LOG="$TMP/stderr.log"
SRV_LOG="$TMP/server.log"
cleanup() { [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null || true; rm -rf "$TMP"; }
trap cleanup EXIT INT TERM
# Preflight: tomllib requires py3.11+
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)' \
|| { echo "SKIP/FAIL: need python3 >= 3.11 for tomllib"; exit 1; }
# Fake pyproject with local version 0.2.0
mkdir -p "$TMP/pkg"
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
PORTFILE="$TMP/port"
WWW="$TMP/www"
# Start a fixture server that serves $WWW and writes its bound port to PORTFILE.
# FAIL_500_PATH (optional): when set, requests with that exact path return HTTP 500
# instead of the default SimpleHTTPRequestHandler behavior. Used by Case I (5xx).
start_server() {
rm -f "$PORTFILE" "$SRV_LOG"
PORTFILE="$PORTFILE" WWW="$WWW" FAIL_500_PATH="${FAIL_500_PATH:-}" python3 - 2>"$SRV_LOG" <<'PY' &
import os, http.server, socketserver, functools
www = os.environ["WWW"]
os.makedirs(www, exist_ok=True)
fail_500_path = os.environ.get("FAIL_500_PATH", "")
class H(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=www, **kw)
def do_GET(self):
if fail_500_path and self.path == fail_500_path:
self.send_error(500, "fixture: forced 500")
return
super().do_GET()
httpd = socketserver.TCPServer(("127.0.0.1", 0), H)
with open(os.environ["PORTFILE"], "w") as f:
f.write(str(httpd.server_address[1]))
httpd.serve_forever()
PY
SRV_PID=$!
for _ in $(seq 1 50); do [ -s "$PORTFILE" ] && break; sleep 0.1; done
# Liveness check: even if PORTFILE never landed, surface the server's stderr
# so a crashed fixture python process produces a real error instead of a
# vague timeout.
if ! kill -0 "$SRV_PID" 2>/dev/null; then
echo "FAIL: fixture server process died before binding" >&2
if [ -s "$SRV_LOG" ]; then
echo "--- captured server stderr ---" >&2
cat "$SRV_LOG" >&2
echo "--- end server stderr ---" >&2
fi
exit 1
fi
if [ ! -s "$PORTFILE" ]; then
echo "FAIL: fixture server failed to bind/write PORTFILE within 5s" >&2
if [ -s "$SRV_LOG" ]; then
echo "--- captured server stderr ---" >&2
cat "$SRV_LOG" >&2
echo "--- end server stderr ---" >&2
fi
exit 1
fi
PORT="$(cat "$PORTFILE")"
}
stop_server() { kill "$SRV_PID" 2>/dev/null || true; wait "$SRV_PID" 2>/dev/null || true; SRV_PID=""; unset FAIL_500_PATH; }
serve_published() {
# Realistic PyPI-shaped response: info.version AND a releases dict (single
# released version). Real PyPI always returns `releases`; the bare-info shape
# was a test-only shortcut that the max-over-releases logic doesn't match.
# File list contains one non-yanked dict so the script's yanked filter (which
# excludes empty/all-yanked file lists) still counts this as a live release.
mkdir -p "$WWW/pypi/copilotkit"
printf '{"info":{"version":"%s"},"releases":{"%s":[{"yanked":false}]}}' "$1" "$1" > "$WWW/pypi/copilotkit/json"
}
# Serve a fuller PyPI-shaped response: info.version + a releases dict whose keys
# are the version strings. $1 = info.version, remaining args = release keys.
# Each release key gets a single non-yanked file entry so it counts as live.
serve_published_with_releases() {
mkdir -p "$WWW/pypi/copilotkit"
local info="$1"; shift
local rels="" k
for k in "$@"; do
[ -z "$rels" ] && rels="\"$k\":[{\"yanked\":false}]" || rels="$rels,\"$k\":[{\"yanked\":false}]"
done
printf '{"info":{"version":"%s"},"releases":{%s}}' "$info" "$rels" > "$WWW/pypi/copilotkit/json"
}
# Serve a custom raw JSON body at /pypi/copilotkit/json. Used by Cases G and H
# to inject yanked file lists or omit the `releases` key entirely.
serve_raw_json() {
mkdir -p "$WWW/pypi/copilotkit"
printf '%s' "$1" > "$WWW/pypi/copilotkit/json"
}
run() {
PYPROJECT_PATH="$TMP/pkg/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1
}
# Run and assert non-zero exit. Captures the script's exit status BEFORE the
# pipeline (a `| tail` rhs would mask the lhs exit code). Returns 0 if the
# script failed as expected, non-zero otherwise. PYPROJECT path overridable
# via $1.
run_expect_fail() {
local pyproj="${1:-$TMP/pkg/pyproject.toml}"
set +e
PYPROJECT_PATH="$pyproj" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
"$SCRIPT" >"$TMP/stdout.log" 2>"$STDERR_LOG"
local ec=$?
set -e
if [ "$ec" -eq 0 ]; then return 1; fi
return 0
}
fail() {
echo "FAIL: $1" >&2
if [ -s "$STDERR_LOG" ]; then
echo "--- captured stderr ---" >&2
cat "$STDERR_LOG" >&2
echo "--- end stderr ---" >&2
fi
exit 1
}
# Case A: published == local (0.2.0) -> should_publish=false (no-op)
# Also assert GITHUB_OUTPUT emission: the script must append should_publish=,
# name=, and version= lines when GITHUB_OUTPUT is set.
rm -rf "$WWW"; serve_published "0.2.0"; start_server
GHO="$TMP/gho.txt"; : > "$GHO"
OUT="$(GITHUB_OUTPUT="$GHO" PYPROJECT_PATH="$TMP/pkg/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1)"
echo "A: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "no-op: got '$OUT'"
grep -Fxq 'should_publish=false' "$GHO" || fail "GITHUB_OUTPUT missing should_publish=false (got: $(cat "$GHO"))"
grep -Fxq 'name=copilotkit' "$GHO" || fail "GITHUB_OUTPUT missing name=copilotkit (got: $(cat "$GHO"))"
grep -Fxq 'version=0.2.0' "$GHO" || fail "GITHUB_OUTPUT missing version=0.2.0 (got: $(cat "$GHO"))"
stop_server
# Case B: published < local (0.1.91 < 0.2.0) -> should_publish=true (exactly one pkg)
rm -rf "$WWW"; serve_published "0.1.91"; start_server
OUT="$(run)"; echo "B: $OUT"; [ "$OUT" = "true copilotkit 0.2.0" ] || fail "bump: got '$OUT'"
stop_server
# Case C: package missing (404) -> should_publish=true (NEW)
rm -rf "$WWW"; mkdir -p "$WWW"; start_server
OUT="$(run)"; echo "C: $OUT"; [ "$OUT" = "true copilotkit 0.2.0" ] || fail "new-pkg: got '$OUT'"
stop_server
# Case D: info.version is LOWER than the true max in releases. PyPI's info.version
# is the LATEST-UPLOADED, not the highest — an out-of-order patch upload to an
# old line can produce this state. The script must compute the max over the
# numeric-parseable releases keys, not trust info.version.
# releases = {0.1.0, 0.2.0}, info.version=0.1.0, local=0.2.0 -> 0.2.0==0.2.0 -> false.
# Explicitly (re)write pyproject so this case doesn't implicitly depend on
# Case A's setup persisting through the prior cases.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
rm -rf "$WWW"; serve_published_with_releases "0.1.0" "0.1.0" "0.2.0"; start_server
OUT="$(run)"; echo "D: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "max-over-releases: got '$OUT'"
stop_server
# Case E: releases contains a non-numeric prerelease key alongside numeric. The
# script must ignore non-numeric published keys (not abort on them) and compare
# against the numeric max. info.version is the prerelease (rc1); local is 0.2.1.
# numeric max published = 0.2.0 < 0.2.1 -> should_publish=true.
rm -rf "$WWW"
mkdir -p "$TMP/pkg2"
cat > "$TMP/pkg2/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.1"
TOML
serve_published_with_releases "0.2.1rc1" "0.2.0" "0.2.1rc1"; start_server
OUT="$(PYPROJECT_PATH="$TMP/pkg2/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1)"
echo "E: $OUT"; [ "$OUT" = "true copilotkit 0.2.1" ] || fail "non-numeric-released-ignored: got '$OUT'"
stop_server
# Case F (zero-pad): published has only key "0.2" (live); local pyproject "0.2.0".
# PEP 440 treats 0.2 == 0.2.0, so should_publish must be false. Without
# zero-padding the comparison, (0,2,0) > (0,2) would wrongly yield True and
# trigger a duplicate-version uv publish that PyPI rejects with 400.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
rm -rf "$WWW"; serve_published_with_releases "0.2" "0.2"; start_server
OUT="$(run)"; echo "F: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "zero-pad: got '$OUT'"
stop_server
# Case G (yanked): releases = {0.2.0:[non-yanked], 0.99.0:[yanked]}, local 0.2.1.
# The fully-yanked 0.99.0 must be excluded when computing the published max so
# a yanked bogus high version can't block legitimate 0.2.x bumps. Live max =
# 0.2.0 < 0.2.1 -> should_publish=true.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.1"
TOML
rm -rf "$WWW"
serve_raw_json '{"info":{"version":"0.2.0"},"releases":{"0.2.0":[{"yanked":false}],"0.99.0":[{"yanked":true}]}}'
start_server
OUT="$(run)"; echo "G: $OUT"; [ "$OUT" = "true copilotkit 0.2.1" ] || fail "yanked-excluded: got '$OUT'"
stop_server
# Case H (missing-releases fail-loud): HTTP 200 with body missing the
# `releases` key entirely. This is a malformed/unexpected PyPI response and a
# publish gate must NOT silently treat it as "new package" — only a genuine
# 404 means NEW. Script must exit non-zero.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
rm -rf "$WWW"
serve_raw_json '{"info":{"version":"0.2.0"}}'
start_server
run_expect_fail || fail "missing-releases must fail loud (script exited 0; stdout=$(cat "$TMP/stdout.log" 2>/dev/null))"
echo "H: non-zero exit as expected"
stop_server
# Case I (5xx coverage): server returns HTTP 500. The script already fails on
# any unexpected non-200/404 status, so this is GREEN immediately — pure
# coverage to lock in the 5xx-fails-loud contract.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
rm -rf "$WWW"; mkdir -p "$WWW"
FAIL_500_PATH="/pypi/copilotkit/json" start_server
run_expect_fail || fail "5xx must fail loud (script exited 0)"
echo "I: non-zero exit as expected"
stop_server
echo "ALL PASS"
@@ -0,0 +1,227 @@
/**
* CLI wrapper for the post-release #engr Slack notification builder.
*
* Thin glue around the pure buildReleaseNotification() function in
* ./lib/build-release-notification.ts. The truth-table logic lives (and is
* unit-tested) there; this file only:
* 1. reads the release signals from env vars (set by the notify job from
* needs.* outputs/results + workflow inputs),
* 2. resolves the npm-scope package count from release.config.json
* (defensively — a cosmetic count must never suppress a real alert),
* 3. calls the pure builder, and
* 4. writes `message=` and `should_post=` to GITHUB_OUTPUT.
*
* Env vars (all optional; absent → empty string):
* MODE needs.build.outputs.mode ("stable" | "prerelease" | "")
* NPM_RESULT needs.publish.result ("success" | "failure" | "skipped" | ...)
* NPM_VER needs.publish.outputs.version
* BUILD_RESULT needs.build.result (catches npm build-stage failures)
* NPM_INTENDED notify-job event-derived npm release intent ("true" | ...) (gates the npm FAILURE arm)
* PY_PUB needs.build-python.outputs.should_publish ("true" | ...)
* PY_INTENDED notify-job event-derived Python release intent ("true" | ...) (gates the PyPI FAILURE arm)
* PY_RESULT needs.publish-python.result
* PY_BUILD_RESULT needs.build-python.result (catches PyPI build-stage failures)
* PY_VER needs.build-python.outputs.version
* SCOPE needs.build.outputs.scope ("monorepo" | "angular")
* DRY_RUN inputs.dry-run ("true" | "false" | "")
* RUN_URL this workflow run URL
* RELEASE_URL GitHub Release URL (npm release notes)
* NPM_URL scope-correct npm package/org page URL
* PY_URL PyPI project page URL
*
* Usage: pnpm tsx scripts/release/build-release-notification.ts
*/
import fs from "fs";
import path from "path";
import { randomBytes } from "crypto";
import { fileURLToPath } from "url";
import { buildReleaseNotification } from "./lib/build-release-notification.js";
import type {
ReleaseMode,
JobResult,
BuildReleaseNotificationResult,
} from "./lib/build-release-notification.js";
import { getScopeConfig, loadConfig } from "./lib/config.js";
import type { ReleaseScope } from "./lib/config.js";
function env(name: string): string {
return process.env[name] ?? "";
}
const KNOWN_MODES: readonly ReleaseMode[] = ["stable", "prerelease", ""];
const KNOWN_JOB_RESULTS: readonly JobResult[] = [
"success",
"failure",
"cancelled",
"skipped",
"",
];
/**
* Validate a raw GitHub Actions job-result env value against the known
* JobResult set, degrading LOUDLY to "failure" (page-on-uncertainty) on any
* unrecognized value. A mis-wired `needs.<job>.result` env (typo, renamed job,
* an Actions value we don't model) must not be cast through unchecked.
*
* DIRECTION ASYMMETRY (intentional): RESULT values drive FAILURE-gating, and
* for a status notifier whose thesis is "never swallow a real failure" an
* unknown result is anomalous and must err toward PAGING, not silence — so it
* degrades to "failure". This is safe precisely because the failure arms are
* intent-gated (npmIntended/pyIntended): a degraded result only pages on a real
* release attempt, never on a routine non-release merge. By contrast
* resolveModeSafe degrades to "" — MODE drives SUCCESS-gating, where fabricating
* "stable" would falsely claim a publish that didn't happen. The ::warning::
* makes the degradation visible in the run log either way.
*/
export function resolveJobResultSafe(raw: string): JobResult {
if ((KNOWN_JOB_RESULTS as readonly string[]).includes(raw)) {
return raw as JobResult;
}
console.warn(
`::warning::resolveJobResultSafe: unrecognized job result "${raw}" (expected one of: success, failure, cancelled, skipped, or empty) — coercing to "failure" (page-on-uncertainty; the intent gates ensure this only pages on a real release).`,
);
return "failure";
}
/**
* Validate the raw MODE env value against the known ReleaseMode set, degrading
* LOUDLY to "" (treated as "npm lane didn't run" — the neutral, safe default)
* on any unrecognized value. A typo'd MODE must not be cast through unchecked.
*
* DIRECTION ASYMMETRY (intentional, opposite of resolveJobResultSafe): MODE
* drives the npm SUCCESS-gating (success requires mode==="stable"). Degrading a
* typo to a fabricated "stable" would FALSELY claim a publish that may not have
* happened, so MODE degrades to the neutral "" — never inventing a success.
* This does NOT swallow failures: the npm-failure arm keys off the event-derived
* npmIntended + the job RESULTS (gated only by the canary suppression), so a real
* stable failure still pages even with a degraded MODE. RESULT values, by
* contrast, drive FAILURE-gating and so degrade toward "failure"
* (page-on-uncertainty) in resolveJobResultSafe. The ::warning:: surfaces either
* degradation in the run log.
*/
export function resolveModeSafe(raw: string): ReleaseMode {
if ((KNOWN_MODES as readonly string[]).includes(raw)) {
return raw as ReleaseMode;
}
console.warn(
`::warning::resolveModeSafe: unrecognized MODE "${raw}" (expected one of: stable, prerelease, or empty) — coercing to "" (treated as "npm lane did not run").`,
);
return "";
}
/**
* Resolve the npm-scope package count from release.config.json, degrading to 0
* on ANY error (unknown scope, missing/corrupt config, etc.). A cosmetic
* package count must NEVER throw and suppress a real release alert.
*/
export function resolvePackageCountSafe(scope: string): number {
try {
// Any scope defined in release.config.json has a package list; anything
// else (e.g. a python-only run with an empty scope) has no npm packages to
// count. Membership comes from the config itself so a newly added scope
// can never drift out of sync with this notifier.
if (scope in loadConfig().scopes) {
return getScopeConfig(scope as ReleaseScope).packages.length;
}
return 0;
} catch (err) {
// Degrade to 0 — the message simply omits the count rather than crashing a
// status notifier over a cosmetic detail. But surface the error in the run
// log (don't swallow silently): a corrupt/missing release.config.json
// should be visible, and the builder will render "published to npm
// (`latest`)" with no count parenthetical (never "0 packages").
console.warn(
`::warning::resolvePackageCountSafe: failed to resolve npm package count for scope "${scope}" — rendering without a package count. ${
err instanceof Error ? err.message : String(err)
}`,
);
return 0;
}
}
/**
* Serialize the builder result to a GITHUB_OUTPUT file using a per-write RANDOM
* heredoc delimiter (GitHub's documented pattern), so message content can never
* collide with / prematurely terminate the heredoc.
*/
export function writeGithubOutput(
outputPath: string,
result: BuildReleaseNotificationResult,
): void {
const delimiter = `EOF_${randomBytes(8).toString("hex")}`;
fs.appendFileSync(
outputPath,
`message<<${delimiter}\n${result.message}\n${delimiter}\n`,
);
fs.appendFileSync(outputPath, `should_post=${result.shouldPost}\n`);
}
function main(): void {
const scope = env("SCOPE");
const result = buildReleaseNotification({
mode: resolveModeSafe(env("MODE")),
npmResult: resolveJobResultSafe(env("NPM_RESULT")),
npmVer: env("NPM_VER"),
buildResult: resolveJobResultSafe(env("BUILD_RESULT")),
npmIntended: env("NPM_INTENDED"),
pyPub: env("PY_PUB"),
pyIntended: env("PY_INTENDED"),
pyResult: resolveJobResultSafe(env("PY_RESULT")),
pyBuildResult: resolveJobResultSafe(env("PY_BUILD_RESULT")),
pyVer: env("PY_VER"),
scope,
dryRun: env("DRY_RUN") === "true",
packageCount: resolvePackageCountSafe(scope),
runUrl: env("RUN_URL"),
releaseUrl: env("RELEASE_URL"),
npmUrl: env("NPM_URL"),
pyUrl: env("PY_URL"),
});
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath) {
writeGithubOutput(outputPath, result);
} else if (process.env.GITHUB_ACTIONS === "true") {
// A status notifier that cannot write its `should_post`/`message` outputs
// is broken: the Post step gates on those outputs, so silently no-op'ing
// would swallow a real release alert. Fail loud under Actions.
console.error(
"::error::GITHUB_OUTPUT is unset under GitHub Actions — cannot emit should_post/message for the release notification.",
);
process.exit(1);
}
// Console echo (always useful in logs; the sole output channel for an
// explicit local/no-Actions invocation).
console.log(`should_post=${result.shouldPost}`);
if (result.message) {
console.log(`message:\n${result.message}`);
}
}
// Only run when invoked directly as a CLI, not when imported by tests.
// Apply fs.realpathSync to BOTH sides so a symlinked checkout (where the module
// path and argv[1] resolve to the same real file through different symlinks)
// can't make main() silently not run. But realpathSync THROWS (ENOENT) if
// argv[1] doesn't resolve on disk — which would crash before main() and
// swallow a real release alert. So guard it: on a realpath throw, fall back to
// a path.resolve()-normalized compare (no disk resolution) so the normal
// direct-invoke path still runs the notifier. Normalize BOTH sides with
// path.resolve — modulePath is already absolute (fileURLToPath), but argv[1]
// may be relative, so a bare string compare could spuriously fail and silently
// skip main() on a realpath throw.
function isInvokedDirectly(): boolean {
if (process.argv[1] == null) return false;
const modulePath = fileURLToPath(import.meta.url);
try {
return fs.realpathSync(modulePath) === fs.realpathSync(process.argv[1]);
} catch {
return path.resolve(modulePath) === path.resolve(process.argv[1]);
}
}
if (isInvokedDirectly()) {
main();
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Bump package versions for a prerelease (runs in the secrets-free build job).
*
* This is extracted from prerelease.ts so that version bumping happens before
* the build, in a job that has no access to NPM_TOKEN or other publish secrets.
* The publish job then receives pre-built, correctly-versioned artifacts.
*
* Usage: tsx scripts/release/bump-prerelease.ts --scope <scope from release.config.json> [--suffix <label>]
*/
import {
getCurrentVersion,
computePrereleaseVersion,
bumpPackages,
getPackagesForScope,
} from "./lib/versions.js";
import { loadConfig, type ReleaseScope } from "./lib/config.js";
// Valid scopes come from release.config.json — the single source of truth.
const VALID_SCOPES = Object.keys(loadConfig().scopes);
function main() {
const argv = process.argv.slice(2);
const suffixIdx = argv.indexOf("--suffix");
const suffix = suffixIdx !== -1 ? argv[suffixIdx + 1] : undefined;
const scopeIdx = argv.indexOf("--scope");
const scope = (
scopeIdx !== -1 ? argv[scopeIdx + 1] : null
) as ReleaseScope | null;
if (!scope || !VALID_SCOPES.includes(scope)) {
console.error(
`Usage: bump-prerelease.ts --scope <${VALID_SCOPES.join("|")}> [--suffix <label>]`,
);
process.exit(1);
}
const config = loadConfig();
const distTag = config.prereleaseTag;
const currentVersion = getCurrentVersion(scope);
const prereleaseVersion = computePrereleaseVersion(currentVersion, suffix);
console.log(`Scope: ${scope}`);
console.log(`Current version: ${currentVersion}`);
console.log(`Prerelease version: ${prereleaseVersion}`);
console.log(`Dist tag: ${distTag}`);
// Bump versions in working directory (no commit)
const updated = bumpPackages(scope, prereleaseVersion);
console.log(`\nBumped ${updated.length} packages to ${prereleaseVersion}`);
for (const p of updated) {
console.log(` ${p.name}: ${p.oldVersion} -> ${p.newVersion}`);
}
}
main();
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Detect whether sdk-python/pyproject.toml declares a version newer than what's
# published on PyPI. Emits GitHub Actions outputs: should_publish, name, version.
# Also prints "<should_publish> <name> <version>" to stdout for test consumption.
# PYPI_BASE_URL overridable for tests (default https://pypi.org). Requires py3.11+.
set -euo pipefail
PYPROJECT="${PYPROJECT_PATH:-sdk-python/pyproject.toml}"
PYPI_BASE_URL="${PYPI_BASE_URL:-https://pypi.org}"
# Parse name + version from pyproject (tomllib, py3.11+). Capture explicitly so a
# parse failure aborts (heredoc-fed `read` would otherwise mask it under set -e).
PYOUT="$(python3 - "$PYPROJECT" <<'PY'
import sys, tomllib
with open(sys.argv[1], "rb") as f:
data = tomllib.load(f)
poetry = data.get("tool", {}).get("poetry", {})
project = data.get("project", {})
name = poetry.get("name") or project.get("name")
version = poetry.get("version") or project.get("version")
if not name or not version:
sys.exit("could not read name/version from pyproject")
print(name)
print(version)
PY
)" || { echo "ERROR: failed to parse ${PYPROJECT}" >&2; exit 1; }
NAME="$(printf '%s\n' "$PYOUT" | sed -n 1p)"
VERSION="$(printf '%s\n' "$PYOUT" | sed -n 2p)"
echo "Local: ${NAME}==${VERSION}" >&2
# Fetch published version, distinguishing 404 (new package) from other failures.
# Capture curl stderr so transport errors (DNS/TLS/connection refused) surface to
# the operator instead of being erased into a vague "HTTP 000".
RESP="$(mktemp)"; CURL_ERR="$(mktemp)"; trap 'rm -f "$RESP" "$CURL_ERR"' EXIT
CODE="$(curl -sS --max-time 30 --retry 3 --retry-all-errors --retry-connrefused -o "$RESP" -w '%{http_code}' "${PYPI_BASE_URL}/pypi/${NAME}/json" 2>"$CURL_ERR" || echo "000")"
case "$CODE" in
200)
# Compute the MAX numeric-parseable version from the `releases` dict (the
# complete set of released versions). `info.version` is the LATEST-UPLOADED,
# not necessarily the highest — out-of-order patch uploads to an old line
# can produce info.version < max(releases). Non-numeric keys (prereleases
# like "0.2.0rc1", dev/post tags) are filtered out, not aborted on.
#
# Exclude fully-yanked releases: each release maps to a list of file dicts
# with a "yanked" bool. A version with an empty file list or all files
# yanked is NOT a live release and must be skipped — otherwise a yanked
# bogus high version (e.g. 0.99.0) permanently blocks legitimate bumps.
#
# If a 200 response lacks a "releases" key, FAIL LOUD: that's not a "new
# package" signal (only 404 is), it's malformed/unexpected JSON for a
# publish gate. If no live numeric release exists, treat as NEW.
PUBLISHED="$(python3 - "$RESP" <<'PY'
import sys, json, re
with open(sys.argv[1]) as f:
data = json.load(f)
if not isinstance(data, dict) or "releases" not in data or data.get("releases") is None:
sys.exit("missing or null 'releases' key in PyPI JSON response")
releases = data["releases"]
numeric = []
for k, files in releases.items():
if not re.fullmatch(r"\d+(\.\d+)*", k):
continue
# Live iff at least one non-yanked file exists. Empty list -> excluded.
if isinstance(files, list) and any(not f.get("yanked", False) for f in files):
numeric.append(k)
if not numeric:
print("")
else:
best = max(numeric, key=lambda v: tuple(int(x) for x in v.split(".")))
print(best)
PY
)" || { echo "ERROR: bad JSON from PyPI (or missing releases key)" >&2; exit 1; }
if [ -z "$PUBLISHED" ]; then
echo "Published: ${NAME} has no live numeric releases — treating as NEW" >&2
else
echo "Published: ${NAME}==${PUBLISHED}" >&2
fi ;;
404)
PUBLISHED=""; echo "Not found on PyPI — treating as NEW" >&2 ;;
000)
echo "ERROR: curl transport/connection failure contacting ${PYPI_BASE_URL}/pypi/${NAME}/json (HTTP 000 = no response, not an HTTP status)" >&2
if [ -s "$CURL_ERR" ]; then
echo "--- curl stderr ---" >&2; cat "$CURL_ERR" >&2; echo "--- end curl stderr ---" >&2
fi
exit 1 ;;
*)
echo "ERROR: unexpected HTTP ${CODE} from ${PYPI_BASE_URL}/pypi/${NAME}/json" >&2
if [ -s "$CURL_ERR" ]; then
echo "--- curl stderr ---" >&2; cat "$CURL_ERR" >&2; echo "--- end curl stderr ---" >&2
fi
exit 1 ;;
esac
if [ -z "$PUBLISHED" ]; then
SHOULD_PUBLISH="true"
else
# Plain X.Y.Z numeric-tuple comparison (no third-party deps). The stable lane
# only ships dotted-numeric LOCAL versions; refuse non-numeric LOCAL loudly.
# PUBLISHED is already guaranteed numeric (filtered above when computing max).
# Zero-pad the shorter tuple before comparing so "0.2" and "0.2.0" compare
# equal (PEP 440). Without padding, (0,2,0) > (0,2) wrongly yields True and
# triggers a duplicate-version `uv publish` that PyPI rejects with 400.
SHOULD_PUBLISH="$(python3 - "$VERSION" "$PUBLISHED" <<'PY'
import sys, re
def parse_local(v):
if not re.fullmatch(r"\d+(\.\d+)*", v):
sys.exit(f"non-numeric local version not supported on stable lane: {v!r}")
return tuple(int(x) for x in v.split("."))
local = parse_local(sys.argv[1])
pub = tuple(int(x) for x in sys.argv[2].split("."))
n = max(len(local), len(pub))
local += (0,) * (n - len(local))
pub += (0,) * (n - len(pub))
print("true" if local > pub else "false")
PY
)" || exit 1
fi
echo "should_publish=${SHOULD_PUBLISH}" >&2
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{ echo "should_publish=${SHOULD_PUBLISH}"; echo "name=${NAME}"; echo "version=${VERSION}"; } >> "$GITHUB_OUTPUT"
fi
echo "${SHOULD_PUBLISH} ${NAME} ${VERSION}"
@@ -0,0 +1,169 @@
/**
* AI-powered release notes generator + Notion draft creator.
*
* 1. Reads the raw changelog from release-notes.md
* 2. Calls Claude API to generate polished release notes
* 3. Creates a Notion page with the draft (for human editing)
* 4. Writes release-notes.md with the AI version
* 5. Outputs the Notion page URL + ID for the workflow
*
* Env vars:
* ANTHROPIC_API_KEY — for AI generation (falls back to raw if missing)
* NOTION_API_KEY — for creating the Notion draft (skipped if missing)
* NOTION_RELEASE_NOTES_PAGE — parent page ID in Notion
*
* Usage: tsx scripts/release/generate-ai-release-notes.ts <version>
*/
import fs from "fs";
import path from "path";
import https from "https";
import { spawnSync } from "child_process";
import { ROOT } from "./lib/config.js";
import { createReleaseDraft } from "./lib/notion.js";
function getRecentCommits(count = 50): string {
const result = spawnSync(
"git",
["log", "--oneline", `-${count}`, "--no-merges"],
{ cwd: ROOT, encoding: "utf8" },
);
return result.stdout.trim();
}
function callAnthropic(apiKey: string, prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
messages: [{ role: "user", content: prompt }],
});
const options = {
hostname: "api.anthropic.com",
path: "/v1/messages",
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk: string) => (data += chunk));
res.on("end", () => {
try {
const parsed = JSON.parse(data);
if (parsed.content?.[0]) {
resolve(parsed.content[0].text);
} else {
reject(new Error(`Unexpected API response: ${data}`));
}
} catch (e) {
reject(e);
}
});
});
req.on("error", reject);
req.write(body);
req.end();
});
}
async function main() {
const version = process.argv[2];
if (!version) {
console.error("Usage: generate-ai-release-notes.ts <version>");
process.exit(1);
}
const releaseNotesPath = path.join(ROOT, "release-notes.md");
if (!fs.existsSync(releaseNotesPath)) {
console.error("release-notes.md not found. Run prepare-release.ts first.");
process.exit(1);
}
const rawChangelog = fs.readFileSync(releaseNotesPath, "utf8");
let finalNotes = rawChangelog;
// Step 1: AI-enhance the release notes if API key is available
const anthropicKey = process.env.ANTHROPIC_API_KEY;
if (anthropicKey) {
console.log("Generating AI-enhanced release notes...");
const recentCommits = getRecentCommits();
const prompt = `You are writing release notes for CopilotKit v${version}, an open-source AI agent framework for React applications.
Here is the raw changelog extracted from git history:
${rawChangelog}
Here are the recent git commits for additional context:
${recentCommits}
Write polished, user-facing release notes for a GitHub Release. Guidelines:
- Start with a brief (1-2 sentence) summary of the release
- Group changes into clear sections (Features, Fixes, Breaking Changes as applicable)
- Write in a professional but approachable tone
- Focus on what users care about — what changed and why it matters
- Include any migration notes for breaking changes
- Keep it concise — no filler, no marketing speak
- Use markdown formatting
- Do NOT include a title/header — the GitHub Release title will be "v${version}"
Output ONLY the release notes content, nothing else.`;
try {
finalNotes = await callAnthropic(anthropicKey, prompt);
fs.writeFileSync(releaseNotesPath, finalNotes);
console.log("AI-enhanced release notes written to release-notes.md");
} catch (err: any) {
console.error(`AI generation failed: ${err.message}`);
console.log("Falling back to raw changelog.");
}
} else {
console.log(
"No ANTHROPIC_API_KEY found. Using raw changelog as release notes.",
);
}
// Step 2: Create a Notion draft page for human editing
const notionKey = process.env.NOTION_API_KEY;
const notionParent = process.env.NOTION_RELEASE_NOTES_PAGE;
if (notionKey && notionParent) {
console.log("Creating Notion release notes draft...");
try {
const { pageId, url } = await createReleaseDraft(version, finalNotes);
console.log(`Notion draft created: ${url}`);
// Write the Notion reference so the publish workflow can find it
const notionRef = { pageId, url, version };
const refPath = path.join(ROOT, "release-notes-notion.json");
fs.writeFileSync(refPath, JSON.stringify(notionRef, null, 2) + "\n");
// Output for CI
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath) {
fs.appendFileSync(outputPath, `notion_url=${url}\n`);
fs.appendFileSync(outputPath, `notion_page_id=${pageId}\n`);
}
} catch (err: any) {
console.error(`Notion draft creation failed: ${err.message}`);
console.log("Continuing without Notion draft.");
}
} else {
console.log(
"No NOTION_API_KEY/NOTION_RELEASE_NOTES_PAGE found. Skipping Notion draft.",
);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,705 @@
import { describe, it, expect } from "vitest";
import { buildReleaseNotification } from "./build-release-notification.js";
import type { BuildReleaseNotificationInput } from "./build-release-notification.js";
const RUN_URL = "https://github.com/CopilotKit/CopilotKit/actions/runs/123";
const RELEASE_URL =
"https://github.com/CopilotKit/CopilotKit/releases/tag/v1.2.3";
const NPM_URL = "https://www.npmjs.com/org/copilotkit";
const ANGULAR_NPM_URL = "https://www.npmjs.com/package/@copilotkit/angular";
const PY_URL = "https://pypi.org/project/copilotkit/0.9.0/";
// A neutral baseline where nothing has acted. Each test overrides only the
// fields relevant to its truth-table row.
function base(
overrides: Partial<BuildReleaseNotificationInput> = {},
): BuildReleaseNotificationInput {
return {
mode: "",
npmResult: "skipped",
npmVer: "",
buildResult: "skipped",
npmIntended: "false",
pyPub: "false",
pyIntended: "false",
pyResult: "skipped",
pyBuildResult: "skipped",
pyVer: "",
scope: "monorepo",
dryRun: false,
packageCount: 16,
runUrl: RUN_URL,
releaseUrl: RELEASE_URL,
npmUrl: NPM_URL,
pyUrl: PY_URL,
...overrides,
};
}
describe("buildReleaseNotification", () => {
// ---- dry-run --------------------------------------------------------------
it("suppresses dry-run — no post", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
dryRun: true,
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- npm lane: prerelease canary fully suppressed -------------------------
it("suppresses prerelease (canary) success — no post", () => {
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmResult: "success",
npmVer: "1.2.3-canary.1",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("suppresses prerelease (canary) npm failure — no post", () => {
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmIntended: "true",
npmResult: "failure",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("suppresses prerelease (canary) build failure — no post (would otherwise fire)", () => {
// Slot-6 strengthening: this row would emit a lane-level failure if the
// prerelease suppression on the npm lane regressed. buildResult=failure
// alone fires the alert UNLESS mode === "prerelease".
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmIntended: "true",
npmResult: "skipped",
buildResult: "failure",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- npm lane: stable success --------------------------------------------
it("stable npm success → npm success line (monorepo, N packages)", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
"🚀 *CopilotKit monorepo v1.2.3* published to npm (`latest`, 16 packages) · " +
`<${RELEASE_URL}|Release notes> · <${NPM_URL}|npm>`,
);
});
it("stable npm success with empty releaseUrl → success line, NO broken Release-notes link", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
releaseUrl: "",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
"🚀 *CopilotKit monorepo v1.2.3* published to npm (`latest`, 16 packages) · " +
`<${NPM_URL}|npm>`,
);
// The broken "/releases/tag/" empty link must never appear.
expect(r.message).not.toContain("Release notes");
expect(r.message).not.toContain("<|");
});
it("angular scope uses the angular package npm URL", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "2.0.0",
buildResult: "success",
scope: "angular",
packageCount: 1,
npmUrl: ANGULAR_NPM_URL,
}),
);
expect(r.message).toContain(`<${ANGULAR_NPM_URL}|npm>`);
expect(r.message).toContain("*CopilotKit angular v2.0.0*");
expect(r.message).toContain("(`latest`, 1 package)");
});
// ---- npm lane: failure (lane-level wording) -------------------------------
it("stable npm failure → lane-level red alert (NOT 'npm publish failed')", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmIntended: "true",
npmResult: "failure",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
// A post-publish step (tag/release) may have failed while publish itself
// succeeded — never claim "publish failed".
expect(r.message).not.toContain("publish failed");
});
it("stable npm publish step SUCCEEDED (version emitted) but JOB failed at a later step → FAILURE line, NOT success (if/else ordering)", () => {
// The central npm-lane design claim: a populated version does NOT force a
// success line. The publish step can succeed and emit a version while the
// JOB still ends in `failure` (e.g. a later tag/release step broke). The
// success arm requires npmResult === "success"; here npmResult is "failure",
// so the `else if (npmResult === "failure" ...)` branch wins → lane-level
// "release failed". This locks the if/else ORDERING: failure result beats a
// populated version. Wording stays lane-level ("release failed"), never
// "publish failed", precisely because publish itself may have succeeded.
const r = buildReleaseNotification(
base({
mode: "stable",
npmIntended: "true",
npmResult: "failure",
npmVer: "1.2.3",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
// Must NOT render a success line despite the populated version.
expect(r.message).not.toContain("🚀");
expect(r.message).not.toContain("published to npm");
// Lane-level, never step-level.
expect(r.message).not.toContain("publish failed");
});
it("build failure (mode='', npm skipped) → lane-level npm/build red alert", () => {
// A failure in the build job before the publish job ran: npmResult is
// "skipped" and mode is empty, but buildResult is "failure".
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "true",
npmResult: "skipped",
buildResult: "failure",
scope: "monorepo",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
expect(r.message).not.toContain("publish failed");
});
it("npm publish failure with empty mode (npmResult='failure') → lane-level red alert (no mode-coupling swallow)", () => {
// The npm FAILURE arm keys off npmIntended (the notify job's event-derived
// release intent) AND the job result — NOT on mode === "stable" (that would
// swallow a real stable publish failure whose mode output came back empty).
// An empty mode with a real publish failure on a genuine release attempt
// still pages.
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "true",
npmResult: "failure",
buildResult: "success",
scope: "monorepo",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
expect(r.message).not.toContain("publish failed");
});
// ---- npm lane: EVENT-DERIVED intent gate ---------------------------------
it("npmIntended='true' + buildResult='failure' → npm red ALERT (intent gate open)", () => {
// The notify job determined an npm release was actually attempted
// (release/publish/* merge or a workflow_dispatch). A build-job failure on a
// genuine npm release must page — independent of needs.build outputs.
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "true",
npmResult: "skipped",
buildResult: "failure",
scope: "monorepo",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
});
it("npmIntended='false' + buildResult='failure' → NEUTRAL (no npm release attempted)", () => {
// The notify job runs on EVERY merged PR. A routine non-release merge whose
// build job flakes (or a stray failure) carries no npm release intent
// (not a release/publish/* merge, not a dispatch), so the lane stays quiet.
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "false",
npmResult: "skipped",
buildResult: "failure",
scope: "monorepo",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- PyPI lane: independent of MODE --------------------------------------
it("PyPI success → only PyPI line", () => {
const r = buildReleaseNotification(
base({ pyPub: "true", pyResult: "success", pyVer: "0.9.0" }),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
"🐍 *copilotkit (Python SDK) v0.9.0* published to PyPI · " +
`<${PY_URL}|PyPI>`,
);
});
it("PyPI failure → lane-level PyPI red alert", () => {
const r = buildReleaseNotification(
base({ pyIntended: "true", pyPub: "true", pyResult: "failure" }),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("build-python failure during a REAL Python release (pyPub='true', publish-python skipped) → PyPI red alert", () => {
// The previously-silent gap: on a genuine Python release where build-python
// FAILS, publish-python is skipped (its `if` requires
// build-python.result == 'success') → pyResult === 'skipped'. Keying the
// failure lane off pyBuildResult catches this so a real release that broke
// at build still pages, instead of posting nothing.
const r = buildReleaseNotification(
base({
pyIntended: "true",
pyPub: "true",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("build-python CANCELLED during a real Python release → NEUTRAL (no false red on deliberate cancel)", () => {
// A cancelled build-python reports result 'cancelled' (NOT 'failure'), even
// when it hits timeout-minutes. The failure lane keys off
// pyBuildResult === 'failure', so a cancel stays neutral.
const r = buildReleaseNotification(
base({
pyIntended: "true",
pyPub: "true",
pyResult: "skipped",
pyBuildResult: "cancelled",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("build-python failure WITHOUT intent (pyIntended='false', pyBuildResult='failure') → NO post (routine PR flake)", () => {
// build-python runs on EVERY merged PR; a transient build-python failure on
// an unrelated PR (no release intent) must NOT page. The pyIntended === 'true'
// gate is what prevents this false-RED.
const r = buildReleaseNotification(
base({
pyIntended: "false",
pyPub: "",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("build-python without intent (pyIntended='false') → NO post (routine PR, no false red)", () => {
// build-python runs on EVERY merged PR; only its inner steps are
// pyproject-gated. A transient build-python failure on an unrelated
// (docs/npm-only) PR must NOT page a PyPI failure — the notify job's
// event-derived pyIntended is false (no Python release attempted).
const r = buildReleaseNotification(
base({ pyIntended: "false", pyPub: "", pyResult: "skipped" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- PyPI lane: EARLY-INTENT failure gate (closes the should_publish gap) -
it("build-python failure AT/BEFORE detect on a genuine Python release (pyIntended='true', pyPub='' — detect never emitted) → PyPI red ALERT", () => {
// THE CLOSED GAP. should_publish (pyPub) is emitted at the END of the detect
// step. A build-python failure at/before detect (PyPI API outage,
// setup-python failure, malformed pyproject) on a genuine Python release
// never reaches the should_publish echo → pyPub="". Gating the failure arm
// on pyPub silently swallowed this. The notify job's event-derived
// pyIntended (computed independently of the build jobs) catches it.
const r = buildReleaseNotification(
base({
pyIntended: "true",
pyPub: "",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("routine PR build-python flake (pyIntended='false', pyBuildResult='failure') → NEUTRAL (no false-RED)", () => {
// build-python runs on EVERY merged PR; an npm/docs-only PR's transient
// build-python failure carries no release intent (the notify job's
// pyIntended is false — the merged PR did not change sdk-python/pyproject.toml
// and this is not a python_publish dispatch), so the lane stays quiet.
const r = buildReleaseNotification(
base({
pyIntended: "false",
pyPub: "",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("python release intended (pyIntended='true', pyBuildResult='failure') → PyPI red ALERT", () => {
// An explicit Python release intent (a python_publish=true dispatch, or a
// merged PR that bumped sdk-python/pyproject.toml). A build failure before
// detect emits no should_publish, but the event-derived pyIntended signal
// still fires the alert.
const r = buildReleaseNotification(
base({
pyIntended: "true",
pyPub: "",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("prerelease + BOTH lanes failing → ONLY the PyPI red line, npm fully suppressed (canary)", () => {
// A mode=prerelease dispatch where the npm lane fails AND the PyPI lane
// fails. The npm failure is canary noise → fully suppressed by
// mode === "prerelease". The PyPI lane is mode-independent, so its real
// failure (pyPub="true", pyResult="failure") still pages. Exactly one red
// line, the PyPI one.
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmIntended: "true",
npmResult: "failure",
buildResult: "failure",
pyIntended: "true",
pyPub: "true",
pyResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
// No npm line: the npm failure marker "*CopilotKit" (the npm line's bold
// prefix) must be absent. NB: the RUN_URL contains "CopilotKit/CopilotKit",
// so assert on the line prefix, not the bare org name.
expect(r.message).not.toContain("*CopilotKit");
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("prerelease + python_publish success → npm suppressed, PyPI line still posts", () => {
// A mode=prerelease dispatch that also runs the Python lane must still
// announce the real PyPI publish — the canary suppression is npm-only.
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmResult: "success",
npmVer: "1.2.3-canary.1",
buildResult: "success",
pyPub: "true",
pyResult: "success",
pyVer: "0.9.0",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).not.toContain("🚀");
expect(r.message).not.toContain("canary");
expect(r.message).toBe(
"🐍 *copilotkit (Python SDK) v0.9.0* published to PyPI · " +
`<${PY_URL}|PyPI>`,
);
});
// ---- cancelled is NEUTRAL everywhere -------------------------------------
it("npm cancelled (mode=stable) → no line (neutral, no false red)", () => {
const r = buildReleaseNotification(
base({ mode: "stable", npmResult: "cancelled", buildResult: "success" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("build cancelled → no line (neutral)", () => {
const r = buildReleaseNotification(
base({ mode: "", npmResult: "skipped", buildResult: "cancelled" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("PyPI cancelled → no line (neutral)", () => {
const r = buildReleaseNotification(
base({ pyPub: "true", pyResult: "cancelled" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("build-python succeeded but publish-python cancelled (should_publish=true) → no line (neutral)", () => {
// Distinct from the row above: here build-python passed (pyBuildResult
// 'success') and the publish job was cancelled. Neither a build failure nor
// a publish failure → neutral, no false red.
const r = buildReleaseNotification(
base({ pyPub: "true", pyResult: "cancelled", pyBuildResult: "success" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- skipped lanes contribute nothing ------------------------------------
it("python-only run (npm lane skipped) → only PyPI line, NO false red", () => {
const r = buildReleaseNotification(
base({
mode: "",
npmResult: "skipped",
buildResult: "skipped",
pyPub: "true",
pyResult: "success",
pyVer: "0.9.0",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).not.toContain("🔴");
expect(r.message).toBe(
"🐍 *copilotkit (Python SDK) v0.9.0* published to PyPI · " +
`<${PY_URL}|PyPI>`,
);
});
it("npm-only run (PyPI lane not acting) → only npm line", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
pyPub: "false",
pyResult: "skipped",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toContain("🚀 *CopilotKit monorepo v1.2.3*");
expect(r.message).not.toContain("🐍");
expect(r.message).not.toContain("🔴");
});
// ---- both lanes ----------------------------------------------------------
it("both lanes succeed → one message with both lines", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
pyPub: "true",
pyResult: "success",
pyVer: "0.9.0",
}),
);
expect(r.shouldPost).toBe(true);
const expected =
"🚀 *CopilotKit monorepo v1.2.3* published to npm (`latest`, 16 packages) · " +
`<${RELEASE_URL}|Release notes> · <${NPM_URL}|npm>\n` +
"🐍 *copilotkit (Python SDK) v0.9.0* published to PyPI · " +
`<${PY_URL}|PyPI>`;
expect(r.message).toBe(expected);
});
it("both lanes fail → one message with both lane-level red lines", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmIntended: "true",
npmResult: "failure",
buildResult: "success",
pyIntended: "true",
pyPub: "true",
pyResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>\n` +
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
// ---- nothing acted -------------------------------------------------------
it("nothing acted (npm skipped, PyPI not publishing) → no post, empty message", () => {
const r = buildReleaseNotification(base());
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- no-false-success guards ---------------------------------------------
it("stable npm success with empty version → no npm success line (no false success)", () => {
// npmResult=success but no version is an anomalous state — do not claim
// success. With buildResult=success there is also no failure to report.
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("PyPI success with empty version → no PyPI success line (no false success)", () => {
const r = buildReleaseNotification(
base({ pyPub: "true", pyResult: "success", pyVer: "" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("pluralization: monorepo scope with N packages → 'N packages'", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
packageCount: 16,
}),
);
expect(r.message).toContain("(`latest`, 16 packages)");
});
it("packageCount=0 (unknown/degraded) → success line WITHOUT a packages count parenthetical", () => {
// A degraded/missing release.config.json resolves to 0 packages. The
// builder must NOT render the self-contradictory "(`latest`, 0 packages)";
// it omits the count parenthetical entirely → "published to npm (`latest`)".
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
packageCount: 0,
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toContain("published to npm (`latest`)");
expect(r.message).not.toContain("packages");
expect(r.message).not.toContain("0 package");
expect(r.message).toBe(
"🚀 *CopilotKit monorepo v1.2.3* published to npm (`latest`) · " +
`<${RELEASE_URL}|Release notes> · <${NPM_URL}|npm>`,
);
});
// ---- empty-scope rendering (no doubled words / double spaces) -------------
it("empty scope on the FAILURE path → clean 'CopilotKit release failed' (no 'release release')", () => {
// A stable build failure where scope is empty must not render
// "CopilotKit release release failed".
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "true",
npmResult: "skipped",
buildResult: "failure",
scope: "",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit release failed* · <${RUN_URL}|View run>`,
);
expect(r.message).not.toContain("release release");
expect(r.message).not.toContain(" ");
});
it("empty scope on the SUCCESS path → clean version line (no double space)", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
scope: "",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).not.toContain(" ");
expect(r.message).toBe(
"🚀 *CopilotKit v1.2.3* published to npm (`latest`, 16 packages) · " +
`<${RELEASE_URL}|Release notes> · <${NPM_URL}|npm>`,
);
});
it("npm success but mode not stable (defensive) → no npm success line", () => {
// mode "" with a success result must not produce an npm success line.
// buildResult=success means no failure either → nothing posts.
const r = buildReleaseNotification(
base({
mode: "",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
});
@@ -0,0 +1,303 @@
/**
* Pure message-builder for the post-release #engr Slack notification.
*
* This is the load-bearing truth table for what (if anything) gets posted to
* Slack after the publish-release.yml workflow runs. It is deliberately a PURE
* function of its inputs so the full truth table can be unit-tested without any
* GitHub Actions / network involvement. The thin CLI wrapper
* (scripts/release/build-release-notification.ts) parses env vars, resolves the
* package count from release.config.json, calls this function, and writes the
* result to GITHUB_OUTPUT.
*
* Failure model — TWO INDEPENDENT LANES (npm + PyPI):
*
* - dry-run → no post (entirely suppressed).
*
* - npm lane (canary npm fully suppressed — success AND failure — when
* mode === "prerelease", because npm canaries are noise):
* • SUCCESS line when mode==stable && npmResult==success && npmVer set.
* The "<releaseUrl|Release notes>" link is included only when
* releaseUrl is non-empty. This empty-releaseUrl guard is retained as
* DEFENSE-IN-DEPTH: the empty-releaseUrl-on-SUCCESS state is NOT
* currently reachable — the tag step is `if: success()`, so a tag-step
* failure flips the publish JOB to `failure`, routing to the failure
* arm (npmResult != "success") rather than rendering an empty link. The
* guard exists so that a FUTURE change making the tag step
* continue-on-error (publish success + empty tag output) cannot render a
* broken empty "<|Release notes>" / "/releases/tag/" link. Do NOT
* remove it. The "(`latest`, N packages)" count parenthetical is OMITTED
* when the resolved packageCount is 0 (unknown/degraded config) — never
* print the self-contradictory "0 packages".
* • FAILURE alert (lane-level, NOT step-level) when
* npmIntended && (npmResult==failure || buildResult==failure) &&
* mode != "prerelease". Gated on the event-derived npmIntended (computed
* in the notify job from the github.event payload — a workflow_dispatch
* or a merged release/publish/* PR) and the job results, with the canary
* suppression. NOT additionally gated on mode==stable (which would
* swallow a real stable publish failure whose mode output came back
* empty). See the inline LANE SYMMETRY note. The publish step may have
* succeeded with a LATER tag/release step failing, so the wording is
* "release failed", never "publish failed".
*
* - PyPI lane (INDEPENDENT of MODE — PyPI has no canary concept, so a
* mode=prerelease dispatch that also runs python_publish must still
* announce the real PyPI publish. NB: this mode-independence only matters
* when the python lane was actually dispatched; on a pure npm release the
* lane is skipped and contributes nothing):
* • SUCCESS line when pyPub=="true" && pyResult==success && pyVer set.
* (Success legitimately requires detect to have run and emitted
* should_publish, so pyPub is the correct success gate.)
* • FAILURE alert when pyIntended &&
* (pyResult==failure || pyBuildResult==failure), where pyIntended is the
* notify job's event-derived Python-release intent (a python_publish
* dispatch, OR a merged PR that changed sdk-python/pyproject.toml per
* the GitHub PR changed-files API). Symmetric with the npm lane. The
* pyBuildResult arm closes the gap where build-python FAILS during a
* genuine release → publish-python is skipped (its `if` requires
* build-python.result == 'success') → pyResult is "skipped", so a bare
* pyResult check would post nothing. CRITICAL — gate on the
* build-job-INDEPENDENT intent, not pyPub: should_publish (pyPub) is
* emitted only at the END of the detect step, so a build-python failure
* AT/BEFORE detect on a genuine release (PyPI API outage, setup-python
* failure, malformed pyproject) never emits it → pyPub="" → a pyPub-gated
* failure arm SILENTLY SWALLOWS the alert. pyIntended is computed in the
* notify job itself from the github.event payload + the PR changed-files
* API, so it does NOT depend on the build jobs running at all. pyIntended
* also keeps routine non-Python PRs quiet: build-python runs on EVERY
* merged PR, but a docs/npm-only PR neither changed pyproject.toml nor is
* a python_publish dispatch, so a transient build flake stays neutral. A
* CANCELLED build reports "cancelled" (NOT "failure") and stays neutral
* — no false-RED on a deliberate cancel.
*
* - cancelled is NEUTRAL everywhere — never a failure line. (GitHub has no
* timeout-specific result; a job hitting timeout-minutes reports
* "cancelled", which correctly stays neutral.) This matches the
* showcase-notify convention and avoids false-red pages on deliberate
* cancels (e.g. concurrency cancel-in-progress).
*
* - a skipped lane contributes NOTHING (no false red).
* - shouldPost is true iff ≥1 line (success OR failure) was emitted; an empty
* message never posts.
*
* See build-release-notification.test.ts for the exhaustive truth table.
*/
export type ReleaseMode = "stable" | "prerelease" | "";
/**
* GitHub Actions `result` values for a needed job. These are the ONLY values
* GitHub emits: success | failure | cancelled | skipped (plus "" when unset).
* There is no timeout-specific result — a job that hits timeout-minutes
* reports "cancelled". We only treat "success" and "failure" as actionable;
* "skipped"/"cancelled"/"" are neutral.
*/
export type JobResult = "success" | "failure" | "skipped" | "cancelled" | "";
export interface BuildReleaseNotificationInput {
/** needs.build.outputs.mode — "stable" | "prerelease" | "" (empty when npm lane didn't run). */
mode: ReleaseMode;
/** needs.publish.result — the npm publish job result. */
npmResult: JobResult;
/** needs.publish.outputs.version — the published npm version (empty unless stable success). */
npmVer: string;
/** needs.build.result — the npm build job result (catches build-stage failures). */
buildResult: JobResult;
/**
* NPM_INTENDED — "true" when the notify job determined an npm release was
* actually attempted, computed in the notify job itself from the
* github.event payload (a workflow_dispatch, or a merged release/publish/*
* PR) — independent of whether/how the build jobs ran. The npm FAILURE arm
* gates on this so a build-job failure on a genuine npm release always pages,
* even if needs.build emitted no usable outputs.
*/
npmIntended: string;
/** needs.build-python.outputs.should_publish — "true" when the PyPI lane acted. */
pyPub: string;
/**
* PY_INTENDED — "true" when the notify job determined a Python release was
* actually intended, computed in the notify job itself: a workflow_dispatch
* with python_publish=true, OR a merged PR that changed
* sdk-python/pyproject.toml (determined via the GitHub PR changed-files API,
* not the build job's outputs). This is the build-job-INDEPENDENT Python
* release-intent signal. should_publish (pyPub) is emitted only at the END of
* build-python's `detect` step, so a build-python failure at/before detect on
* a genuine release never emits should_publish — gating the failure arm on
* pyIntended (not pyPub) closes that silent-swallow gap.
*/
pyIntended: string;
/** needs.publish-python.result — the PyPI publish job result. */
pyResult: JobResult;
/**
* needs.build-python.result — the PyPI build job result. Lets the failure
* lane catch a build-stage failure that skips publish-python (whose `if`
* requires build-python.result == 'success', so pyResult becomes "skipped").
*/
pyBuildResult: JobResult;
/** needs.build-python.outputs.version — the published PyPI version. */
pyVer: string;
/** needs.build.outputs.scope — "monorepo" | "angular". */
scope: string;
/** inputs.dry-run — true on a dry-run dispatch. */
dryRun: boolean;
/** Number of packages in the npm scope (from release.config.json). */
packageCount: number;
/** URL to this workflow run (for failure "View run" links). */
runUrl: string;
/** URL to the GitHub Release for the npm release (for "Release notes" link). May be empty. */
releaseUrl: string;
/** URL to the npm package/org page (for the "npm" link). Scope-correct. */
npmUrl: string;
/** URL to the PyPI project page (for the "PyPI" link). */
pyUrl: string;
}
export interface BuildReleaseNotificationResult {
/** The combined Slack message (mrkdwn). Empty when shouldPost is false. */
message: string;
/** True iff there is ≥1 success line OR ≥1 failure line. */
shouldPost: boolean;
}
function pluralizePackages(count: number): string {
return count === 1 ? "1 package" : `${count} packages`;
}
/**
* Build the #engr Slack message for a release run. Pure function: same
* inputs always produce the same output.
*/
export function buildReleaseNotification(
input: BuildReleaseNotificationInput,
): BuildReleaseNotificationResult {
const empty: BuildReleaseNotificationResult = {
message: "",
shouldPost: false,
};
// Dry-run never posts (no real publish happened on any lane).
if (input.dryRun) {
return empty;
}
// Both failure lanes gate on an event-derived intent signal computed in the
// notify job (NOT on the build jobs' outputs/results). npmIntended is "true"
// when an npm release was actually attempted; pyIntended is "true" when a
// Python release was actually intended. See the per-lane notes below.
const npmIntended = input.npmIntended === "true";
const pyIntended = input.pyIntended === "true";
const lines: string[] = [];
// Render the scope cleanly when empty: a bare " " or doubled "release"
// word must never appear. With scope present we get "CopilotKit <scope> …";
// with scope empty we collapse to "CopilotKit …" (no extra word/space).
const scopeSegment = input.scope ? `${input.scope} ` : "";
// --- npm lane -----------------------------------------------------------
// Canary (prerelease) npm runs are fully suppressed — success AND failure —
// because npm canaries are noise. The PyPI lane below is NOT gated on this.
if (input.mode !== "prerelease") {
if (
input.mode === "stable" &&
input.npmResult === "success" &&
input.npmVer
) {
// Build the success line, including the "Release notes" link ONLY when
// releaseUrl is non-empty. This guard is retained as DEFENSE-IN-DEPTH (see
// header): the empty-releaseUrl-on-SUCCESS state is NOT currently
// reachable — the tag step is `if: success()`, so a tag-step failure flips
// the publish JOB to `failure` and routes to the failure arm rather than
// this success arm. The guard protects against a FUTURE change making the
// tag step continue-on-error (publish success + empty tag output), which
// would otherwise render a broken empty "<|Release notes>" /
// "/releases/tag/" link. Do NOT remove it.
const releaseNotes = input.releaseUrl
? `<${input.releaseUrl}|Release notes> · `
: "";
// Omit the "(`latest`, N packages)" count parenthetical entirely when
// the package count is unknown/degraded (0) — never print "0 packages".
const countSuffix =
input.packageCount > 0
? ` (\`latest\`, ${pluralizePackages(input.packageCount)})`
: " (`latest`)";
lines.push(
`🚀 *CopilotKit ${scopeSegment}v${input.npmVer}* published to npm` +
`${countSuffix} · ` +
`${releaseNotes}<${input.npmUrl}|npm>`,
);
} else if (
npmIntended &&
(input.npmResult === "failure" || input.buildResult === "failure")
) {
// Lane-level wording: the publish step may have succeeded while a later
// tag/release step failed, so never say "npm publish failed".
//
// The npm FAILURE arm gates on npmIntended AND the job results, with the
// enclosing canary suppression (mode !== "prerelease"). It is NOT gated on
// mode === "stable" (that would swallow a real stable publish failure
// whose mode output came back empty).
//
// LANE SYMMETRY (now both lanes are intent-gated from the notify job's
// event-derived signals): npmIntended is computed in the notify job from
// the github.event payload (a workflow_dispatch, or a merged
// release/publish/* PR) — NOT inferred from the `build` job's
// branch-gating invariant. So a build-job failure that emits no usable
// outputs still pages on a genuine npm release, and a routine non-release
// merge's build flake stays quiet. The PyPI lane below is symmetric: it
// gates on pyIntended, likewise event-derived in the notify job.
lines.push(
`🔴 *CopilotKit ${scopeSegment}release failed* · <${input.runUrl}|View run>`,
);
}
// cancelled / skipped on the npm lane are NEUTRAL → no line.
}
// --- PyPI lane ----------------------------------------------------------
// INDEPENDENT of MODE — PyPI has no canary concept, so the prerelease path
// never suppresses a real PyPI publish.
// pyIntended is the notify job's event-derived Python-release intent signal,
// computed independently of the build jobs (a python_publish dispatch, or a
// merged PR that changed sdk-python/pyproject.toml per the GitHub PR
// changed-files API). The SUCCESS arm still requires pyPub (should_publish)
// because a legitimate success necessarily means detect ran and emitted it;
// only the FAILURE arm must gate on the build-job-independent intent so a
// build-python failure AT/BEFORE detect (which never reaches the
// should_publish echo) still pages.
if (input.pyPub === "true" && input.pyResult === "success" && input.pyVer) {
lines.push(
`🐍 *copilotkit (Python SDK) v${input.pyVer}* published to PyPI · ` +
`<${input.pyUrl}|PyPI>`,
);
} else if (
pyIntended &&
(input.pyResult === "failure" || input.pyBuildResult === "failure")
) {
// Fire on a real Python release attempt when EITHER the publish job failed
// OR the build job failed. Keying off pyBuildResult catches the gap where
// build-python FAILS → publish-python is skipped (its `if` requires
// build-python.result == 'success') → pyResult is "skipped" and the bare
// pyResult check would post nothing.
//
// The gate is pyIntended (the notify job's event-derived Python-release
// intent), NOT pyPub: pyPub (should_publish) is emitted only at the END of
// the detect step, so a build-python failure AT/BEFORE detect on a genuine
// release (PyPI API outage, setup-python failure, malformed pyproject) never
// emits it → pyPub="" → the old pyPub gate silently swallowed the alert.
// pyIntended is computed in the notify job from the github.event payload +
// the PR changed-files API, so it is present regardless of how the build
// jobs ran. pyIntended also keeps routine non-Python PRs quiet: build-python
// runs on EVERY merged PR, but a docs/npm-only PR neither changed
// sdk-python/pyproject.toml nor is a python_publish dispatch, so a transient
// build flake there stays neutral. Use pyBuildResult === "failure" (NOT
// "skipped"): a CANCELLED build reports "cancelled" and stays NEUTRAL, so a
// deliberate cancel never false-REDs.
lines.push(
`🔴 *copilotkit (Python SDK) release failed* · <${input.runUrl}|View run>`,
);
}
if (lines.length === 0) {
return empty;
}
return { message: lines.join("\n"), shouldPost: true };
}
@@ -0,0 +1,432 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import { fileURLToPath } from "url";
import { execFileSync } from "child_process";
import {
writeGithubOutput,
resolvePackageCountSafe,
resolveModeSafe,
resolveJobResultSafe,
} from "../build-release-notification.js";
import * as config from "./config.js";
const WRAPPER = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../build-release-notification.ts",
);
// Resolve the local tsx binary so the subprocess never hits npx's network /
// registry path (which is flaky in CI). Walk up from this file to the repo
// root's node_modules/.bin/tsx.
const REPO_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../..",
);
const TSX_BIN = path.join(REPO_ROOT, "node_modules", ".bin", "tsx");
/**
* Run the wrapper CLI as a subprocess; returns { code, stdout, stderr }.
*
* Builds a CLEAN minimal env (only PATH + the caller's overrides) rather than
* spreading the runner's process.env. This matters because the suite itself may
* run under GitHub Actions with a real GITHUB_OUTPUT / GITHUB_ACTIONS set —
* spreading those in would pollute the fail-loud "GITHUB_OUTPUT unset" test and
* the DRY_RUN coercion cases.
*/
function runWrapper(env: Record<string, string | undefined>): {
code: number;
stdout: string;
stderr: string;
} {
// Strip undefined values so an explicit `KEY: undefined` truly unsets it
// (rather than passing the string "undefined").
const cleanEnv: Record<string, string> = { PATH: process.env.PATH ?? "" };
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) cleanEnv[k] = v;
}
try {
const stdout = execFileSync(TSX_BIN, [WRAPPER], {
env: cleanEnv,
stdio: "pipe",
encoding: "utf8",
});
return { code: 0, stdout, stderr: "" };
} catch (e: unknown) {
const err = e as { status?: number; stdout?: string; stderr?: string };
return {
code: err.status ?? 1,
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
};
}
}
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "release-notify-wrapper-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("writeGithubOutput", () => {
it("round-trips a multi-line message through the GITHUB_OUTPUT heredoc", () => {
const outputPath = path.join(tmpDir, "out.txt");
fs.writeFileSync(outputPath, "");
const message = "line one\nline two · <https://x|y>";
writeGithubOutput(outputPath, { message, shouldPost: true });
const raw = fs.readFileSync(outputPath, "utf8");
// Parse the heredoc the way GitHub Actions does: message<<DELIM ... DELIM.
const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
expect(m![2]).toBe(message);
expect(raw).toContain("should_post=true");
});
it("uses a per-write RANDOM delimiter (not a fixed sentinel)", () => {
const a = path.join(tmpDir, "a.txt");
const b = path.join(tmpDir, "b.txt");
fs.writeFileSync(a, "");
fs.writeFileSync(b, "");
writeGithubOutput(a, { message: "x", shouldPost: true });
writeGithubOutput(b, { message: "x", shouldPost: true });
const delimA = fs.readFileSync(a, "utf8").match(/^message<<(\S+)/m)?.[1];
const delimB = fs.readFileSync(b, "utf8").match(/^message<<(\S+)/m)?.[1];
expect(delimA).toBeTruthy();
expect(delimB).toBeTruthy();
// No fixed sentinel, and two separate writes must differ.
expect(delimA).not.toBe("__RELEASE_NOTIFY_EOF__");
expect(delimA).not.toBe(delimB);
});
it("does not corrupt output when the message itself contains a heredoc-like token", () => {
const outputPath = path.join(tmpDir, "out.txt");
fs.writeFileSync(outputPath, "");
// A pathological message containing the legacy fixed delimiter must not
// prematurely terminate the heredoc.
const message = "__RELEASE_NOTIFY_EOF__\nstill the message";
writeGithubOutput(outputPath, { message, shouldPost: true });
const raw = fs.readFileSync(outputPath, "utf8");
const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
expect(m![2]).toBe(message);
});
});
describe("resolvePackageCountSafe", () => {
it("returns 0 for an unknown scope (early return, no config lookup)", () => {
// An unknown scope is NOT a known npm scope, so it hits the early `return 0`
// BEFORE getScopeConfig is ever called — this exercises the not-a-known-scope
// branch, NOT the try/catch error-swallow path (see the throw test below).
expect(() => resolvePackageCountSafe("does-not-exist")).not.toThrow();
expect(resolvePackageCountSafe("does-not-exist")).toBe(0);
});
it("returns 0 for an empty scope (python-only run)", () => {
expect(resolvePackageCountSafe("")).toBe(0);
});
it("returns the real package count for a known scope (angular)", () => {
// angular has exactly one package in release.config.json.
expect(resolvePackageCountSafe("angular")).toBe(1);
});
it("returns the real package count for the monorepo scope (drift guard)", () => {
// Pins the actual count from release.config.json (16). If the package set
// drifts, this catches the staleness of the hardcoded "16 packages"
// assertions in build-release-notification.test.ts.
expect(resolvePackageCountSafe("monorepo")).toBe(16);
});
it("returns the real package count for the channels scope (channels + channels-ui, drift guard)", () => {
expect(resolvePackageCountSafe("channels")).toBe(2);
});
it("returns the real package count for the channels-slack scope (drift guard)", () => {
expect(resolvePackageCountSafe("channels-slack")).toBe(1);
});
it("resolves a positive count for EVERY scope in release.config.json (anti-drift)", () => {
// Membership is read from the config at runtime, so a newly added scope
// can never silently render without a package count. If this fails for a
// future scope, that scope's package list is empty or the wrapper has
// drifted from release.config.json.
for (const [scope, cfg] of Object.entries(config.loadConfig().scopes)) {
expect(resolvePackageCountSafe(scope)).toBe(cfg.packages.length);
expect(resolvePackageCountSafe(scope)).toBeGreaterThan(0);
}
});
it("swallows a getScopeConfig throw on a KNOWN scope → returns 0 AND emits ::warning::", () => {
// Drive the catch branch (not the early return): stub getScopeConfig to
// throw for a KNOWN scope (monorepo), simulating a corrupt/missing
// release.config.json. The safe wrapper must degrade to 0 and surface the
// failure as a ::warning:: rather than crash the notifier.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const scopeSpy = vi
.spyOn(config, "getScopeConfig")
.mockImplementation(() => {
throw new Error("simulated corrupt release.config.json");
});
expect(() => resolvePackageCountSafe("monorepo")).not.toThrow();
expect(resolvePackageCountSafe("monorepo")).toBe(0);
expect(scopeSpy).toHaveBeenCalledWith("monorepo");
expect(warnSpy).toHaveBeenCalled();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("::warning::")),
).toBe(true);
});
});
describe("resolveModeSafe", () => {
it.each(["stable", "prerelease", ""] as const)(
'passes through the known mode "%s" unchanged',
(mode: string) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(resolveModeSafe(mode)).toBe(mode);
expect(warnSpy).not.toHaveBeenCalled();
},
);
it('coerces an unknown MODE (typo) to "" AND emits ::warning:: (degrade loud, no crash)', () => {
// A typo'd MODE must NOT be cast through unchecked. The safe resolver
// degrades to "" (neutral "npm lane did not run") and surfaces a
// ::warning:: so the degradation isn't silent. "" is the safe default: the
// npm-failure arm keys off job RESULTS (gated only by canary suppression),
// so a real failure still pages — only a stable SUCCESS would degrade, and
// that degradation is now visible in the run log.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => resolveModeSafe("stabel")).not.toThrow();
expect(resolveModeSafe("stabel")).toBe("");
expect(warnSpy).toHaveBeenCalled();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("::warning::")),
).toBe(true);
});
});
describe("resolveJobResultSafe", () => {
it.each(["success", "failure", "cancelled", "skipped", ""] as const)(
'passes through the known job result "%s" unchanged (no warning)',
(result: string) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(resolveJobResultSafe(result)).toBe(result);
expect(warnSpy).not.toHaveBeenCalled();
},
);
it('coerces an unknown job result to "failure" AND emits ::warning:: (page-on-uncertainty, no crash)', () => {
// A mis-wired needs.<job>.result (typo, renamed job, an Actions value we
// don't model) must NOT be cast through unchecked. RESULT values drive
// FAILURE-gating, so for a notifier whose thesis is "never swallow a real
// failure" an unknown result is anomalous and degrades toward "failure"
// (page-on-uncertainty), not silence. The intent gates (npmIntended/
// pyIntended) ensure this only pages on a real release. A ::warning:: makes
// the degradation visible in the run log.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => resolveJobResultSafe("succeeded")).not.toThrow();
expect(resolveJobResultSafe("succeeded")).toBe("failure");
expect(warnSpy).toHaveBeenCalled();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("::warning::")),
).toBe(true);
});
});
describe("wrapper CLI fail-loud (subprocess)", () => {
it("fails loud (non-zero + ::error::) when running under Actions with GITHUB_OUTPUT unset", () => {
// GITHUB_ACTIONS=true signals an Actions context; with no GITHUB_OUTPUT a
// status notifier that cannot write its output must fail visibly.
const { code, stderr } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: undefined,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
});
expect(code).not.toBe(0);
expect(stderr).toContain("::error::");
}, 30000);
it("writes output and exits 0 when GITHUB_OUTPUT is set", () => {
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
SCOPE: "monorepo",
});
expect(code).toBe(0);
const raw = fs.readFileSync(out, "utf8");
expect(raw).toContain("should_post=true");
expect(raw).toMatch(/^message<<\S+/m);
}, 30000);
});
describe("wrapper CLI DRY_RUN string coercion (subprocess)", () => {
// DRY_RUN is the inputs.dry-run boolean stringified by Actions. It gates EVERY
// production notification, yet the env→boolean coercion is only exercisable at
// this string layer. Only the exact string "true" suppresses the post.
function postFor(dryRun: string): { code: number; raw: string } {
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
SCOPE: "monorepo",
DRY_RUN: dryRun,
});
return { code, raw: fs.readFileSync(out, "utf8") };
}
it('DRY_RUN="true" → should_post=false (suppressed)', () => {
const { code, raw } = postFor("true");
expect(code).toBe(0);
expect(raw).toContain("should_post=false");
}, 30000);
it('DRY_RUN="false" → posts on an otherwise-successful stable run', () => {
const { code, raw } = postFor("false");
expect(code).toBe(0);
expect(raw).toContain("should_post=true");
}, 30000);
it('DRY_RUN="" (empty) → posts on an otherwise-successful stable run', () => {
const { code, raw } = postFor("");
expect(code).toBe(0);
expect(raw).toContain("should_post=true");
}, 30000);
});
describe("wrapper CLI end-to-end message rendering (subprocess)", () => {
it("mixed lane: npm success + PyPI failure → one 🚀 line and one 🔴 line in one message", () => {
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
NPM_INTENDED: "true",
SCOPE: "monorepo",
PY_INTENDED: "true",
PY_PUB: "true",
PY_RESULT: "failure",
RUN_URL: "https://github.com/CopilotKit/CopilotKit/actions/runs/123",
});
expect(code).toBe(0);
const m = fs
.readFileSync(out, "utf8")
.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
const message = m![2];
expect(message).toContain("🚀");
expect(message).toContain("🔴");
expect(message).toContain("(Python SDK) release failed");
// Exactly two lines (one per lane).
expect(message.split("\n")).toHaveLength(2);
}, 30000);
it("PyPI build failure during a real release (PY_BUILD_RESULT=failure, publish skipped) → 🔴 PyPI alert", () => {
// End-to-end wiring of the PY_BUILD_RESULT env: build-python failed, so
// publish-python was skipped (PY_RESULT=skipped). The notifier must still
// emit the PyPI failure line via the pyBuildResult arm.
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
PY_INTENDED: "true",
PY_PUB: "true",
PY_RESULT: "skipped",
PY_BUILD_RESULT: "failure",
RUN_URL: "https://github.com/CopilotKit/CopilotKit/actions/runs/123",
});
expect(code).toBe(0);
const raw = fs.readFileSync(out, "utf8");
expect(raw).toContain("should_post=true");
const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
const message = m![2];
expect(message).toContain("🔴");
expect(message).toContain("(Python SDK) release failed");
}, 30000);
it("build-skipped routine merge (BUILD_RESULT=skipped, MODE='', SCOPE='', NPM_INTENDED='false', PY_INTENDED='false') → should_post=false (no false red)", () => {
// The dominant real-world case: the notify job runs on EVERY merged PR, but
// the build job is `skipped` on a non-release merge (no release/publish/*
// ref) → MODE/SCOPE come back empty and no Python intent signal is set.
// The notifier must stay completely silent — neither a success nor a
// failure line — so a routine docs/feature merge never pages #engr.
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "",
SCOPE: "",
BUILD_RESULT: "skipped",
NPM_RESULT: "skipped",
NPM_INTENDED: "false",
PY_PUB: "",
PY_INTENDED: "false",
PY_RESULT: "skipped",
PY_BUILD_RESULT: "skipped",
RUN_URL: "https://github.com/CopilotKit/CopilotKit/actions/runs/123",
});
expect(code).toBe(0);
const raw = fs.readFileSync(out, "utf8");
expect(raw).toContain("should_post=false");
}, 30000);
it("packageCount=0 (unknown scope) → success line WITHOUT a packages count", () => {
// An empty/unknown SCOPE resolves to 0 packages; the rendered npm line must
// omit the count parenthetical, never print "0 packages".
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
SCOPE: "",
NPM_URL: "https://www.npmjs.com/org/copilotkit",
});
expect(code).toBe(0);
const m = fs
.readFileSync(out, "utf8")
.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
const message = m![2];
expect(message).toContain("published to npm (`latest`)");
expect(message).not.toContain("packages");
expect(message).not.toContain("0 package");
}, 30000);
});
+66
View File
@@ -0,0 +1,66 @@
import { spawnSync } from "child_process";
import { ROOT } from "./config.js";
export function getLastReleaseTag(): string | null {
const result = spawnSync(
"git",
["tag", "--list", "v*", "--sort=-v:refname"],
{ cwd: ROOT, encoding: "utf8" },
);
const tags = result.stdout.trim().split("\n").filter(Boolean);
for (const tag of tags) {
if (/^v\d+\.\d+\.\d+$/.test(tag)) {
return tag;
}
}
return null;
}
export interface Commit {
hash: string;
subject: string;
}
export function getCommitsSinceLastRelease(): Commit[] {
const lastTag = getLastReleaseTag();
const range = lastTag ? `${lastTag}..HEAD` : "HEAD";
const result = spawnSync(
"git",
["log", range, "--oneline", "--no-merges", "--format=%H %s"],
{ cwd: ROOT, encoding: "utf8" },
);
return result.stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const spaceIdx = line.indexOf(" ");
return {
hash: line.slice(0, spaceIdx),
subject: line.slice(spaceIdx + 1),
};
});
}
export interface ChangesSummary {
lastTag: string | null;
commitCount: number;
commits: Commit[];
oneline: string;
}
export function getChangesSummary(): ChangesSummary {
const lastTag = getLastReleaseTag();
const commits = getCommitsSinceLastRelease();
return {
lastTag,
commitCount: commits.length,
commits,
oneline: commits.map((c) => `- ${c.subject}`).join("\n"),
};
}
+46
View File
@@ -0,0 +1,46 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
export const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../..",
);
export type ReleaseScope =
| "monorepo"
| "angular"
| "channels"
| "channels-discord"
| "channels-intelligence"
| "channels-slack"
| "channels-teams"
| "channels-telegram"
| "channels-whatsapp";
export interface ScopeConfig {
packages: string[];
versionSource: string;
sharedVersion: boolean;
}
export interface ReleaseConfig {
prereleaseTag: string;
scopes: Record<ReleaseScope, ScopeConfig>;
}
export function loadConfig(): ReleaseConfig {
const configPath = path.join(ROOT, "release.config.json");
return JSON.parse(fs.readFileSync(configPath, "utf8"));
}
export function getScopeConfig(scope: ReleaseScope): ScopeConfig {
const config = loadConfig();
const scopeConfig = config.scopes[scope];
if (!scopeConfig) {
throw new Error(
`Unknown scope: ${scope}. Valid scopes: ${Object.keys(config.scopes).join(", ")}`,
);
}
return scopeConfig;
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import { emitGithubOutputs } from "./github-output.js";
let tmpDir: string;
let outputFile: string;
const originalGithubOutput = process.env.GITHUB_OUTPUT;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-output-"));
outputFile = path.join(tmpDir, "output");
fs.writeFileSync(outputFile, "");
process.env.GITHUB_OUTPUT = outputFile;
});
afterEach(() => {
// Restore spies first so they cannot leak into the env/fs cleanup below.
vi.restoreAllMocks();
if (originalGithubOutput === undefined) {
delete process.env.GITHUB_OUTPUT;
} else {
process.env.GITHUB_OUTPUT = originalGithubOutput;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("emitGithubOutputs", () => {
it("appends key=value lines to the GITHUB_OUTPUT file", () => {
emitGithubOutputs({ version: "1.2.3-canary.42", scope: "monorepo" });
expect(fs.readFileSync(outputFile, "utf8")).toBe(
"version=1.2.3-canary.42\nscope=monorepo\n",
);
});
it("appends without truncating prior outputs", () => {
fs.writeFileSync(outputFile, "earlier=value\n");
emitGithubOutputs({ version: "1.2.3" });
expect(fs.readFileSync(outputFile, "utf8")).toBe(
"earlier=value\nversion=1.2.3\n",
);
});
it("is a no-op when GITHUB_OUTPUT is unset", () => {
delete process.env.GITHUB_OUTPUT;
const appendSpy = vi.spyOn(fs, "appendFileSync");
expect(() => emitGithubOutputs({ version: "1.2.3" })).not.toThrow();
expect(appendSpy).not.toHaveBeenCalled();
});
it("throws when a value contains a newline, naming the offending key", () => {
expect(() =>
emitGithubOutputs({ version: "1.2.3\nmalicious=evil" }),
).toThrow(/version/);
});
it("throws when a value contains a carriage return", () => {
expect(() => emitGithubOutputs({ version: "1.2.3\r" })).toThrow(/version/);
});
it("throws when a key contains a newline", () => {
expect(() => emitGithubOutputs({ "bad\nkey": "value" })).toThrow(
/alphanumeric/,
);
});
it("throws when a key contains '='", () => {
expect(() => emitGithubOutputs({ "bad=key": "value" })).toThrow(
/alphanumeric/,
);
});
it("throws when a key contains a space", () => {
expect(() => emitGithubOutputs({ "bad key": "value" })).toThrow(
/alphanumeric/,
);
});
it("throws when a key is empty", () => {
expect(() => emitGithubOutputs({ "": "value" })).toThrow(/alphanumeric/);
});
it("accepts a value containing '=' and writes it verbatim", () => {
emitGithubOutputs({ note: "a=b" });
expect(fs.readFileSync(outputFile, "utf8")).toBe("note=a=b\n");
});
});
+36
View File
@@ -0,0 +1,36 @@
import fs from "fs";
/**
* Append step outputs to the file GitHub Actions exposes via GITHUB_OUTPUT.
*
* The publish-release workflow's "Verify publish step emitted version" guard
* and the downstream summary/tag steps read `steps.publish.outputs.version`
* (and `scope`), so every publish script must emit these after publishing.
* No-op outside CI (GITHUB_OUTPUT unset), e.g. when running locally.
*
* Keys must match GitHub Actions' safe output-name shape
* (`/^[A-Za-z_][A-Za-z0-9_-]*$/`); values must be single-line (no newline or
* carriage return), since GITHUB_OUTPUT's `key=value` form splits on the first
* `=` and cannot carry newlines (multi-line values would need the heredoc
* form, which this helper deliberately does not support).
*/
export function emitGithubOutputs(outputs: Record<string, string>): void {
for (const [key, value] of Object.entries(outputs)) {
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) {
throw new Error(
`emitGithubOutputs: key ${JSON.stringify(key)} is not a valid GitHub Actions output name; keys must be alphanumeric/underscore/dash and start with a letter or underscore.`,
);
}
if (/[\n\r]/.test(value)) {
throw new Error(
`emitGithubOutputs: value for key ${JSON.stringify(key)} contains a newline or carriage return; GITHUB_OUTPUT's key=value form cannot carry newlines (multi-line values would need the heredoc form, which this helper deliberately does not support).`,
);
}
}
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) return;
const lines = Object.entries(outputs)
.map(([key, value]) => `${key}=${value}\n`)
.join("");
fs.appendFileSync(outputPath, lines);
}
+196
View File
@@ -0,0 +1,196 @@
import https from "https";
/**
* Notion API helper for release notes management.
*
* Creates a draft release notes page under the configured parent page.
* On merge, reads the (potentially edited) page content back for the
* GitHub Release body.
*
* Required env vars:
* NOTION_API_KEY — Notion internal integration token
* NOTION_RELEASE_NOTES_PAGE — Parent page ID for release note drafts
*/
const NOTION_API_VERSION = "2022-06-28";
function notionRequest(
apiKey: string,
method: string,
path: string,
body?: unknown,
): Promise<any> {
return new Promise((resolve, reject) => {
const options = {
hostname: "api.notion.com",
path,
method,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Notion-Version": NOTION_API_VERSION,
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk: string) => (data += chunk));
res.on("end", () => {
const parsed = JSON.parse(data);
if (res.statusCode && res.statusCode >= 400) {
reject(
new Error(
`Notion API ${res.statusCode}: ${parsed.message || data}`,
),
);
} else {
resolve(parsed);
}
});
});
req.on("error", reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
/** Convert a markdown string into Notion block children (simplified). */
function markdownToBlocks(markdown: string): any[] {
const blocks: any[] = [];
const lines = markdown.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("### ")) {
blocks.push({
object: "block",
type: "heading_3",
heading_3: {
rich_text: [{ type: "text", text: { content: line.slice(4) } }],
},
});
} else if (line.startsWith("## ")) {
blocks.push({
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: line.slice(3) } }],
},
});
} else if (line.startsWith("- ")) {
blocks.push({
object: "block",
type: "bulleted_list_item",
bulleted_list_item: {
rich_text: [{ type: "text", text: { content: line.slice(2) } }],
},
});
} else if (line.trim() === "") {
continue;
} else {
blocks.push({
object: "block",
type: "paragraph",
paragraph: {
rich_text: [{ type: "text", text: { content: line } }],
},
});
}
}
return blocks;
}
/** Convert Notion blocks back to markdown. */
function blocksToMarkdown(blocks: any[]): string {
const lines: string[] = [];
for (const block of blocks) {
const richText =
block[block.type]?.rich_text
?.map((t: any) => t.plain_text || "")
.join("") || "";
switch (block.type) {
case "heading_1":
lines.push(`# ${richText}`);
break;
case "heading_2":
lines.push(`## ${richText}`);
break;
case "heading_3":
lines.push(`### ${richText}`);
break;
case "bulleted_list_item":
lines.push(`- ${richText}`);
break;
case "numbered_list_item":
lines.push(`1. ${richText}`);
break;
case "paragraph":
lines.push(richText || "");
break;
case "divider":
lines.push("---");
break;
default:
if (richText) lines.push(richText);
}
}
return lines.join("\n");
}
/**
* Create a Notion page with release notes content.
* Returns { pageId, url }.
*/
export async function createReleaseDraft(
version: string,
markdownContent: string,
): Promise<{ pageId: string; url: string }> {
const apiKey = process.env.NOTION_API_KEY;
const parentPageId = process.env.NOTION_RELEASE_NOTES_PAGE;
if (!apiKey || !parentPageId) {
throw new Error("NOTION_API_KEY and NOTION_RELEASE_NOTES_PAGE must be set");
}
const blocks = markdownToBlocks(markdownContent);
const page = await notionRequest(apiKey, "POST", "/v1/pages", {
parent: { page_id: parentPageId },
properties: {
title: {
title: [{ text: { content: `v${version} Release Notes (Draft)` } }],
},
},
children: blocks,
});
return {
pageId: page.id,
url: page.url,
};
}
/**
* Read a Notion page's content back as markdown.
* Used on merge to get the (potentially human-edited) release notes.
*/
export async function readReleaseDraft(pageId: string): Promise<string> {
const apiKey = process.env.NOTION_API_KEY;
if (!apiKey) {
throw new Error("NOTION_API_KEY must be set");
}
const response = await notionRequest(
apiKey,
"GET",
`/v1/blocks/${pageId}/children?page_size=100`,
);
return blocksToMarkdown(response.results);
}
+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import {
parseSemver,
computeNextStableVersion,
computePrereleaseVersion,
bumpPackages,
} from "./versions.js";
let tmpDir: string;
// Mock config — ROOT points to a temp dir for bumpPackages tests
vi.mock("./config.js", async () => {
return {
get ROOT() {
return tmpDir || "/mock";
},
loadConfig: () => ({
prereleaseTag: "canary",
scopes: {
monorepo: {
packages: ["@copilotkit/shared", "@copilotkit/react-core"],
versionSource: "@copilotkit/react-core",
sharedVersion: true,
},
angular: {
packages: ["@copilotkit/angular"],
versionSource: "@copilotkit/angular",
sharedVersion: false,
},
},
}),
getScopeConfig: (scope: string) => {
const scopes: Record<string, any> = {
monorepo: {
packages: ["@copilotkit/shared", "@copilotkit/react-core"],
versionSource: "@copilotkit/react-core",
sharedVersion: true,
},
angular: {
packages: ["@copilotkit/angular"],
versionSource: "@copilotkit/angular",
sharedVersion: false,
},
};
return scopes[scope];
},
};
});
describe("parseSemver", () => {
it("parses a stable version", () => {
expect(parseSemver("1.55.2")).toEqual({
major: 1,
minor: 55,
patch: 2,
prerelease: null,
});
});
it("parses a prerelease version", () => {
expect(parseSemver("1.55.2-canary.1744382400")).toEqual({
major: 1,
minor: 55,
patch: 2,
prerelease: "canary.1744382400",
});
});
it("parses a version with text prerelease", () => {
expect(parseSemver("2.0.0-canary.fix-user-issue")).toEqual({
major: 2,
minor: 0,
patch: 0,
prerelease: "canary.fix-user-issue",
});
});
it("throws on invalid version", () => {
expect(() => parseSemver("not-a-version")).toThrow("Invalid semver");
});
it("throws on empty string", () => {
expect(() => parseSemver("")).toThrow("Invalid semver");
});
});
describe("computeNextStableVersion", () => {
it("bumps patch", () => {
expect(computeNextStableVersion("1.55.2", "patch")).toBe("1.55.3");
});
it("bumps minor", () => {
expect(computeNextStableVersion("1.55.2", "minor")).toBe("1.56.0");
});
it("bumps major", () => {
expect(computeNextStableVersion("1.55.2", "major")).toBe("2.0.0");
});
it("strips prerelease suffix on any bump", () => {
expect(computeNextStableVersion("1.56.0-canary.123", "patch")).toBe(
"1.56.0",
);
});
it("strips prerelease suffix regardless of bump level", () => {
expect(computeNextStableVersion("2.0.0-canary.123", "major")).toBe("2.0.0");
});
it("handles 0.x versions", () => {
expect(computeNextStableVersion("0.1.0", "patch")).toBe("0.1.1");
expect(computeNextStableVersion("0.1.0", "minor")).toBe("0.2.0");
expect(computeNextStableVersion("0.1.0", "major")).toBe("1.0.0");
});
});
describe("computePrereleaseVersion", () => {
it("appends canary tag with timestamp when no suffix given", () => {
const result = computePrereleaseVersion("1.55.2");
expect(result).toMatch(/^1\.55\.2-canary\.\d+$/);
});
it("appends canary tag with custom suffix", () => {
expect(computePrereleaseVersion("1.55.2", "fix-user-issue")).toBe(
"1.55.2-canary.fix-user-issue",
);
});
it("uses the base version as-is (no bump)", () => {
expect(computePrereleaseVersion("1.55.2", "test")).toBe(
"1.55.2-canary.test",
);
expect(computePrereleaseVersion("2.0.0", "test")).toBe("2.0.0-canary.test");
});
it("strips existing prerelease before appending", () => {
expect(computePrereleaseVersion("1.55.2-canary.old", "new")).toBe(
"1.55.2-canary.new",
);
});
});
describe("bumpPackages", () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "release-test-"));
const packagesDir = path.join(tmpDir, "packages");
// Create shared package
const sharedDir = path.join(packagesDir, "shared");
fs.mkdirSync(sharedDir, { recursive: true });
fs.writeFileSync(
path.join(sharedDir, "package.json"),
JSON.stringify({
name: "@copilotkit/shared",
version: "1.55.2",
}),
);
// Create react-core with workspace:* dep on shared
const reactCoreDir = path.join(packagesDir, "react-core");
fs.mkdirSync(reactCoreDir, { recursive: true });
fs.writeFileSync(
path.join(reactCoreDir, "package.json"),
JSON.stringify({
name: "@copilotkit/react-core",
version: "1.55.2",
dependencies: {
"@copilotkit/shared": "workspace:*",
},
}),
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("bumps version fields", () => {
const result = bumpPackages("monorepo", "1.55.3");
expect(result).toHaveLength(2);
expect(result[0].newVersion).toBe("1.55.3");
const pkg = JSON.parse(
fs.readFileSync(
path.join(tmpDir, "packages/shared/package.json"),
"utf8",
),
);
expect(pkg.version).toBe("1.55.3");
});
it("preserves workspace:* protocol in dependencies", () => {
bumpPackages("monorepo", "1.55.3");
const pkg = JSON.parse(
fs.readFileSync(
path.join(tmpDir, "packages/react-core/package.json"),
"utf8",
),
);
expect(pkg.dependencies["@copilotkit/shared"]).toBe("workspace:*");
});
it("updates exact version deps (not workspace protocol)", () => {
// Write react-core with an exact version dep instead of workspace:*
fs.writeFileSync(
path.join(tmpDir, "packages/react-core/package.json"),
JSON.stringify({
name: "@copilotkit/react-core",
version: "1.55.2",
dependencies: {
"@copilotkit/shared": "1.55.2",
},
}),
);
bumpPackages("monorepo", "1.55.3");
const pkg = JSON.parse(
fs.readFileSync(
path.join(tmpDir, "packages/react-core/package.json"),
"utf8",
),
);
expect(pkg.dependencies["@copilotkit/shared"]).toBe("1.55.3");
});
});
+158
View File
@@ -0,0 +1,158 @@
import fs from "fs";
import path from "path";
import {
loadConfig,
getScopeConfig,
ROOT,
type ReleaseScope,
} from "./config.js";
export type BumpLevel = "patch" | "minor" | "major";
interface SemVer {
major: number;
minor: number;
patch: number;
prerelease: string | null;
}
export interface PublishablePackage {
name: string;
dir: string;
pkgJsonPath: string;
pkg: Record<string, any>;
}
/** Find a package directory by its npm name. */
function findPackageDir(packageName: string): string {
const packagesDir = path.join(ROOT, "packages");
for (const dir of fs.readdirSync(packagesDir)) {
const pkgJsonPath = path.join(packagesDir, dir, "package.json");
if (!fs.existsSync(pkgJsonPath)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
if (pkg.name === packageName) return path.join(packagesDir, dir);
}
throw new Error(`Package not found: ${packageName}`);
}
/** Get the current version for a scope (reads from the scope's versionSource package). */
export function getCurrentVersion(scope: ReleaseScope): string {
const scopeConfig = getScopeConfig(scope);
const dir = findPackageDir(scopeConfig.versionSource);
const pkg = JSON.parse(
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
);
return pkg.version;
}
export function parseSemver(version: string): SemVer {
const match = version.match(
/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.\-]+))?(?:\+(.+))?$/,
);
if (!match) {
throw new Error(`Invalid semver: ${version}`);
}
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
prerelease: match[4] || null,
};
}
export function computeNextStableVersion(
currentVersion: string,
bumpLevel: BumpLevel,
): string {
const v = parseSemver(currentVersion);
if (v.prerelease) {
return `${v.major}.${v.minor}.${v.patch}`;
}
switch (bumpLevel) {
case "major":
return `${v.major + 1}.0.0`;
case "minor":
return `${v.major}.${v.minor + 1}.0`;
case "patch":
return `${v.major}.${v.minor}.${v.patch + 1}`;
}
}
export function computePrereleaseVersion(
currentVersion: string,
suffix?: string,
): string {
const v = parseSemver(currentVersion);
const config = loadConfig();
const tag = config.prereleaseTag;
const id = suffix || String(Math.floor(Date.now() / 1000));
return `${v.major}.${v.minor}.${v.patch}-${tag}.${id}`;
}
/** Get all publishable packages for a given scope. */
export function getPackagesForScope(scope: ReleaseScope): PublishablePackage[] {
const scopeConfig = getScopeConfig(scope);
const packageNames = new Set(scopeConfig.packages);
const packagesDir = path.join(ROOT, "packages");
const results: PublishablePackage[] = [];
for (const dir of fs.readdirSync(packagesDir)) {
const pkgJsonPath = path.join(packagesDir, dir, "package.json");
if (!fs.existsSync(pkgJsonPath)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
if (packageNames.has(pkg.name)) {
results.push({
name: pkg.name,
dir: path.join(packagesDir, dir),
pkgJsonPath,
pkg,
});
}
}
return results;
}
/** Bump all packages in a scope to a new version. For sharedVersion scopes, also updates internal deps. */
export function bumpPackages(
scope: ReleaseScope,
newVersion: string,
): { name: string; oldVersion: string; newVersion: string }[] {
const scopeConfig = getScopeConfig(scope);
const packages = getPackagesForScope(scope);
const scopeNames = new Set(scopeConfig.packages);
const updated: { name: string; oldVersion: string; newVersion: string }[] =
[];
for (const p of packages) {
const pkg = JSON.parse(fs.readFileSync(p.pkgJsonPath, "utf8"));
const oldVersion = pkg.version;
pkg.version = newVersion;
// For shared-version scopes, update internal dependency references —
// but only if they use exact versions, not workspace:* protocol
if (scopeConfig.sharedVersion) {
for (const depField of [
"dependencies",
"peerDependencies",
"devDependencies",
] as const) {
if (!pkg[depField]) continue;
for (const depName of Object.keys(pkg[depField])) {
const depValue = pkg[depField][depName];
if (scopeNames.has(depName) && !depValue.startsWith("workspace:")) {
pkg[depField][depName] = newVersion;
}
}
}
}
fs.writeFileSync(p.pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n");
updated.push({ name: p.name, oldVersion, newVersion });
}
return updated;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Prepare a release: bump versions, generate raw release notes.
* Runs inside the "create release PR" workflow.
*
* Usage: tsx scripts/release/prepare-release.ts --bump <patch|minor|major> --scope <scope from release.config.json> [--dry-run]
*/
import fs from "fs";
import path from "path";
import {
getCurrentVersion,
computeNextStableVersion,
bumpPackages,
getPackagesForScope,
type BumpLevel,
} from "./lib/versions.js";
import {
getChangesSummary,
type ChangesSummary,
type Commit,
} from "./lib/changes.js";
import { ROOT, loadConfig, type ReleaseScope } from "./lib/config.js";
function generateRawReleaseNotes(
version: string,
scope: ReleaseScope,
summary: ChangesSummary,
): string {
const lines: string[] = [];
const label = scope === "monorepo" ? "" : ` (${scope})`;
lines.push(`## v${version}${label}`, "");
if (summary.commits.length === 0) {
lines.push("No changes since last release.");
return lines.join("\n");
}
const features: Commit[] = [];
const fixes: Commit[] = [];
const other: Commit[] = [];
for (const c of summary.commits) {
if (/^feat[:(]/.test(c.subject)) features.push(c);
else if (/^fix[:(]/.test(c.subject)) fixes.push(c);
else other.push(c);
}
if (features.length > 0) {
lines.push("### Features", "");
for (const c of features)
lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
lines.push("");
}
if (fixes.length > 0) {
lines.push("### Fixes", "");
for (const c of fixes) lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
lines.push("");
}
if (other.length > 0) {
lines.push("### Other Changes", "");
for (const c of other) lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
lines.push("");
}
return lines.join("\n");
}
// Valid scopes come from release.config.json — the single source of truth.
const VALID_SCOPES = Object.keys(loadConfig().scopes);
function main() {
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
const bumpIdx = argv.indexOf("--bump");
const bumpLevel = (
bumpIdx !== -1 ? argv[bumpIdx + 1] : null
) as BumpLevel | null;
const scopeIdx = argv.indexOf("--scope");
const scope = (
scopeIdx !== -1 ? argv[scopeIdx + 1] : null
) as ReleaseScope | null;
if (!bumpLevel || !["patch", "minor", "major"].includes(bumpLevel)) {
console.error(
"Usage: prepare-release.ts --bump <patch|minor|major> --scope <scope from release.config.json>",
);
process.exit(1);
}
if (!scope || !VALID_SCOPES.includes(scope)) {
console.error(
`Invalid scope: ${scope}. Valid scopes: ${VALID_SCOPES.join(", ")}`,
);
process.exit(1);
}
const currentVersion = getCurrentVersion(scope);
const nextVersion = computeNextStableVersion(currentVersion, bumpLevel);
console.log(`Scope: ${scope}`);
console.log(`Current version: ${currentVersion}`);
console.log(`Bump level: ${bumpLevel}`);
console.log(`Next version: ${nextVersion}`);
const summary = getChangesSummary();
console.log(
`\nCommits since ${summary.lastTag || "beginning"}: ${summary.commitCount}`,
);
if (dryRun) {
console.log("\n[DRY RUN] Would bump these packages:");
for (const p of getPackagesForScope(scope)) {
console.log(` ${p.name}: ${p.pkg.version} -> ${nextVersion}`);
}
console.log("\n[DRY RUN] Exiting.");
return;
}
const updated = bumpPackages(scope, nextVersion);
console.log(`\nBumped ${updated.length} packages to ${nextVersion}`);
const rawNotes = generateRawReleaseNotes(nextVersion, scope, summary);
const releaseNotesPath = path.join(ROOT, "release-notes.md");
fs.writeFileSync(releaseNotesPath, rawNotes);
console.log("Raw release notes written to release-notes.md");
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath) {
fs.appendFileSync(outputPath, `version=${nextVersion}\n`);
fs.appendFileSync(outputPath, `scope=${scope}\n`);
}
console.log(`\nRelease prepared: v${nextVersion} (${scope})`);
}
main();
+122
View File
@@ -0,0 +1,122 @@
/**
* Publish a prerelease to npm (publish-only, no build/test/bump).
*
* Version bumping is handled by bump-prerelease.ts in the secrets-free CI
* build job. Build and test also run there. This script receives pre-built,
* correctly-versioned artifacts and only performs the npm publish step.
*
* Always publishes with the "canary" dist-tag.
*
* Usage: tsx scripts/release/prerelease.ts --scope <scope from release.config.json> [--dry-run]
*/
import { spawnSync } from "child_process";
import { getPackagesForScope } from "./lib/versions.js";
import { ROOT, loadConfig } from "./lib/config.js";
import type { ReleaseScope } from "./lib/config.js";
import { emitGithubOutputs } from "./lib/github-output.js";
function run(cmd: string, args: string[], opts?: { cwd?: string }) {
const result = spawnSync(cmd, args, {
cwd: opts?.cwd ?? ROOT,
stdio: "inherit",
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
}
return result;
}
// Valid scopes come from release.config.json — the single source of truth.
const VALID_SCOPES = Object.keys(loadConfig().scopes);
function main() {
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
const scopeIdx = argv.indexOf("--scope");
const scope = (
scopeIdx !== -1 ? argv[scopeIdx + 1] : null
) as ReleaseScope | null;
if (!scope || !VALID_SCOPES.includes(scope)) {
console.error(
`Usage: prerelease.ts --scope <${VALID_SCOPES.join("|")}> [--dry-run]`,
);
process.exit(1);
}
const config = loadConfig();
const distTag = config.prereleaseTag;
// Read the version from package.json — already bumped by bump-prerelease.ts
// in the CI build job.
const packages = getPackagesForScope(scope);
if (packages.length === 0) {
console.error(
`No packages found for scope "${scope}" — refusing to emit a version for a publish that did nothing.`,
);
process.exit(1);
}
const publishVersion = packages[0].pkg.version;
if (!publishVersion) {
console.error(
`Package ${packages[0].name} has no version field; refusing to publish.`,
);
process.exit(1);
}
console.log(`Scope: ${scope}`);
console.log(`Publishing version: ${publishVersion}`);
console.log(`Dist tag: ${distTag}`);
if (dryRun) {
console.log("\n[DRY RUN] Would publish these packages:");
for (const p of packages) {
console.log(` ${p.name}@${p.pkg.version}`);
}
// Emitting in dry-run is safe — the publish workflow gates both the
// publish step and the verify guard on `inputs.dry-run != true`, so this
// only serves local/e2e verification of the output contract.
emitGithubOutputs({ version: publishVersion, scope });
console.log("\n[DRY RUN] Exiting.");
return;
}
// NOTE: Version bumping is handled by bump-prerelease.ts in the CI build
// job (no secrets). Build and test also run there.
// The publish job receives pre-built artifacts via download-artifact.
// We intentionally do NOT rebuild/retest here to keep NPM_TOKEN out
// of the build process tree.
// Publish each package via pnpm pack + npx npm@11 (OIDC-aware)
console.log("\nPublishing packages...");
for (const p of packages) {
console.log(
` Publishing ${p.name}@${p.pkg.version} with tag ${distTag}...`,
);
run("pnpm", ["pack"], { cwd: p.dir });
const tarball = `${p.name.replace("@", "").replace("/", "-")}-${p.pkg.version}.tgz`;
run(
"npx",
[
"--yes",
"npm@11.15.0",
"publish",
tarball,
"--tag",
distTag,
"--access",
"public",
],
{ cwd: p.dir },
);
}
// The workflow's "Verify publish step emitted version" guard and the
// prerelease summary read these from steps.publish.outputs.
emitGithubOutputs({ version: publishVersion, scope });
console.log(`\nPrerelease published: ${publishVersion} (tag: ${distTag})`);
}
main();

Some files were not shown because too many files have changed in this diff Show More