chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Executable
+213
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"fixtures": [
|
||||
{
|
||||
"match": {},
|
||||
"response": {
|
||||
"content": "Hello! I'm an AI assistant."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)`,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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: "", // we’ll accumulate description lines
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) If it’s 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, "<").replace(/>/g, ">");
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Executable
+219
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useCopilotAction } from "@copilotkit/react-core";
|
||||
|
||||
useCopilotAction({
|
||||
name: "noargs",
|
||||
handler: async (args) => {
|
||||
console.log("No args action");
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useCopilotAction } from "@copilotkit/react-core";
|
||||
|
||||
useCopilotAction({
|
||||
name: "noargs",
|
||||
handler: async () => {
|
||||
console.log("No args action");
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({}));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "copilotkit-test-12345"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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({}));
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"outDir": "lib",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es2017"
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,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");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
);
|
||||
}
|
||||
@@ -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,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}`);
|
||||
});
|
||||
@@ -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,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}`);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
Executable
+123
@@ -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);
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user