feat(FN-3765): add run panel and generation pipeline for CLI printing press
Adds a CLI command generation pipeline with a runner to execute generated commands, run/artifact API routes, and a TestRunnerPanel UI for monitoring execution — complete with tests covering the generator, runner, run routes, and the panel component. Fusion-Task-Id: FN-3765
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
Bundled first-party Fusion plugin that adds a plugin-owned dashboard wizard for drafting an external service CLI definition.
|
||||
|
||||
## v1 scope (FN-3763 + FN-3764)
|
||||
## v1 scope (FN-3763 + FN-3764 + FN-3765)
|
||||
|
||||
- Provides two dashboard views:
|
||||
- **Create Service CLI** (`viewId: wizard`)
|
||||
@@ -11,9 +11,54 @@ Bundled first-party Fusion plugin that adds a plugin-owned dashboard wizard for
|
||||
- Manage view supports list/inspect/edit/regenerate/delete against saved drafts
|
||||
- Saves draft payloads to interim JSON files under:
|
||||
- `<projectRoot>/.fusion/plugins/cli-printing-press/drafts/<id>.json`
|
||||
- Regenerate in v1 is a stub endpoint that re-saves the draft and returns:
|
||||
- `stub: true`
|
||||
- `message: "Regenerate stub — full generation lands in FN-3765/FN-3767"`
|
||||
|
||||
## Run / Test panel (FN-3765)
|
||||
|
||||
Each draft in **Manage Service CLIs** includes a **Run / Test** panel:
|
||||
|
||||
1. Click **Regenerate** to build or refresh a generated CLI artifact for the selected draft.
|
||||
2. Pick an endpoint and fill endpoint parameters.
|
||||
3. Provide credential values in transient password fields.
|
||||
4. Click **Run** to execute the generated CLI against the configured service.
|
||||
|
||||
The panel shows:
|
||||
- status, duration, and redacted argv echo
|
||||
- stdout and stderr
|
||||
- exitCode and timeout state (`timedOut`)
|
||||
|
||||
### Credential contract
|
||||
|
||||
- Credentials are passed to the generated CLI via environment variables named:
|
||||
- `CLIPP_CRED_<UPPER_SNAKE_KEY>`
|
||||
- Credential values are **not persisted** in plugin draft files.
|
||||
- Credential values are redacted from stdout, stderr, and argv echoes before API responses are returned.
|
||||
|
||||
## Plugin API routes
|
||||
|
||||
Plugin views call host-prefixed plugin routes under `/api/plugins/cli-printing-press/`:
|
||||
|
||||
- `POST /drafts` — save draft
|
||||
- `GET /drafts` — list summaries
|
||||
- `GET /drafts/:id` — fetch full draft
|
||||
- `PUT /drafts/:id` — update draft
|
||||
- `DELETE /drafts/:id` — remove draft
|
||||
- `POST /drafts/:id/regenerate` — generate/update CLI artifact and persist artifact metadata
|
||||
- `GET /drafts/:id/artifact` — fetch artifact metadata
|
||||
- `POST /drafts/:id/run` — run generated CLI and return run result
|
||||
|
||||
### Run endpoint behavior
|
||||
|
||||
`POST /drafts/:id/run` returns HTTP 200 for completed runs (including non-zero exits and timeouts), with outcome encoded in payload fields:
|
||||
- `exitCode`
|
||||
- `timedOut`
|
||||
- `stdout`
|
||||
- `stderr`
|
||||
- `argv` (redacted)
|
||||
|
||||
Validation and state errors return:
|
||||
- `400` invalid body/params/timeout
|
||||
- `404` unknown draft id
|
||||
- `409` draft exists but has not been generated yet
|
||||
|
||||
## Provisional architecture assumptions (pending FN-3762/FN-3766)
|
||||
|
||||
@@ -23,22 +68,12 @@ The following choices are intentionally provisional and may be revised by archit
|
||||
- Express route shape and plugin-relative path conventions
|
||||
- Credential union shape for wizard payloads (non-OAuth only in v1)
|
||||
- Draft storage location and JSON schema
|
||||
- Generator implementation details (kept behind `generateCli(...)` abstraction)
|
||||
|
||||
## Deferred follow-ups
|
||||
|
||||
- OAuth credential flows: **FN-3762 / FN-3766**
|
||||
- Run/test actions and real generator execution: **FN-3765**
|
||||
- Canonical storage migration (replace JSON stash): **FN-3766**
|
||||
- Runtime exposure/integration: **FN-3767**
|
||||
- Workflow-step exposure: **FN-3768**
|
||||
|
||||
## Frontend API target
|
||||
|
||||
Plugin views call host-prefixed plugin routes under `/api/plugins/fusion-plugin-cli-printing-press/`:
|
||||
|
||||
- `POST /drafts` — save draft
|
||||
- `GET /drafts` — list summaries
|
||||
- `GET /drafts/:id` — fetch full draft
|
||||
- `PUT /drafts/:id` — update draft
|
||||
- `POST /drafts/:id/regenerate` — v1 stub regenerate response
|
||||
- `DELETE /drafts/:id` — remove draft
|
||||
- Persistent run history: deferred (response-only run results in FN-3765)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CliPrintingPressTestRunner } from "../run/TestRunnerPanel";
|
||||
import type { ServiceDraft } from "../wizard/types";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Play: () => null,
|
||||
RefreshCw: () => null,
|
||||
CheckCircle2: () => null,
|
||||
AlertTriangle: () => null,
|
||||
}));
|
||||
|
||||
function makeDraft(): ServiceDraft {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: "draft-1",
|
||||
name: "Demo",
|
||||
slug: "demo",
|
||||
description: "",
|
||||
baseUrl: "https://example.com",
|
||||
transport: "http",
|
||||
endpoints: [{ id: "e1", name: "Ping", method: "GET", path: "/ping", params: "q" }],
|
||||
credential: { kind: "none" },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CliPrintingPressTestRunner", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("submits run request and renders output states", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/run")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stdout: "ok", stderr: "", exitCode: 0, durationMs: 12, timedOut: false, argv: ["demo.mjs", "--endpoint", "e1", "--q", "***"] }),
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/regenerate")) {
|
||||
return { ok: true, json: async () => ({ draft: makeDraft(), artifact: { draftId: "draft-1", slug: "demo", binPath: "/tmp/demo.mjs", entrypoint: "node", generatedAt: new Date().toISOString() } }) };
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({ error: "missing" }) };
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CliPrintingPressTestRunner draftId="draft-1" draft={makeDraft()} />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
await user.type(screen.getByLabelText("q"), "hello");
|
||||
await user.type(screen.getByPlaceholderText("api_key"), "secret-value");
|
||||
await user.click(screen.getByRole("button", { name: /^Run$/i }));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/run"), expect.objectContaining({ method: "POST" })));
|
||||
const runCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/run"));
|
||||
expect(runCall).toBeTruthy();
|
||||
expect(JSON.parse(String((runCall?.[1] as RequestInit).body))).toMatchObject({ endpointId: "e1", params: { q: "hello" } });
|
||||
expect(await screen.findByText("ok")).toBeTruthy();
|
||||
expect(screen.queryByText("secret-value")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders error and timeout output states", async () => {
|
||||
let calls = 0;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/run")) {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stdout: "", stderr: "boom", exitCode: 1, durationMs: 22, timedOut: false, argv: ["demo.mjs"] }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stdout: "", stderr: "", exitCode: null, durationMs: 33, timedOut: true, argv: ["demo.mjs"] }),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({ error: "missing" }) };
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
render(<CliPrintingPressTestRunner draftId="draft-1" draft={makeDraft()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Run$/i }));
|
||||
expect(await screen.findByText("boom")).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: /^Run$/i }));
|
||||
expect(await screen.findByText("Failed")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { access, mkdtemp, readFile, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateCli } from "../generation/generator.js";
|
||||
import { createDraftStore, getArtifactDir } from "../storage/draft-store.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
|
||||
function makeDraft(id = "d-1"): ServiceDraft {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
name: "Demo",
|
||||
slug: "demo",
|
||||
description: "",
|
||||
baseUrl: "https://example.com",
|
||||
transport: "http",
|
||||
endpoints: [{ id: "ep-1", name: "Ping", method: "GET", path: "/ping", params: "id" }],
|
||||
credential: { kind: "none" },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateCli", () => {
|
||||
it("creates an executable file in artifact dir", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "clipp-gen-"));
|
||||
const draft = makeDraft();
|
||||
const artifact = await generateCli({ draft, outDir: getArtifactDir(draft.id, rootDir) });
|
||||
|
||||
await access(artifact.binPath);
|
||||
const contents = await readFile(artifact.binPath, "utf8");
|
||||
expect(contents).toContain("const draft =");
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const mode = (await stat(artifact.binPath)).mode & 0o777;
|
||||
expect(mode).toBe(0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it("supports persisting generatedAt on rerun", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "clipp-gen-store-"));
|
||||
const store = createDraftStore({ rootDir });
|
||||
const created = await store.create(makeDraft("d-2"));
|
||||
|
||||
const first = await generateCli({ draft: created, outDir: getArtifactDir(created.id, rootDir) });
|
||||
const updated = await store.update(created.id, { generatedAt: first.generatedAt, artifactPath: first.binPath });
|
||||
const second = await generateCli({ draft: updated, outDir: getArtifactDir(updated.id, rootDir) });
|
||||
const updatedAgain = await store.update(created.id, { generatedAt: second.generatedAt, artifactPath: second.binPath });
|
||||
|
||||
expect(updatedAgain.generatedAt).toBe(second.generatedAt);
|
||||
expect(updatedAgain.generatedAt).not.toBe(first.generatedAt);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,9 @@ vi.mock("lucide-react", () => ({
|
||||
Pencil: () => null,
|
||||
RefreshCw: () => null,
|
||||
Trash2: () => null,
|
||||
Play: () => null,
|
||||
CheckCircle2: () => null,
|
||||
AlertTriangle: () => null,
|
||||
}));
|
||||
|
||||
function makeDraft(id: string, name = "Demo"): ServiceDraft {
|
||||
@@ -58,7 +61,8 @@ describe("CliPrintingPressManageView", () => {
|
||||
if (method === "GET" && url.endsWith(`/drafts/${draft2.id}`)) return { ok: true, json: async () => draft2 };
|
||||
if (method === "PUT" && url.endsWith(`/drafts/${draft1.id}`)) return { ok: true, json: async () => updated };
|
||||
if (method === "POST" && url.endsWith(`/drafts/${draft1.id}/regenerate`)) {
|
||||
return { ok: true, json: async () => ({ draft: { ...updated, regeneratedAt: new Date().toISOString() }, stub: true, message: "Regenerate stub — full generation lands in FN-3765/FN-3767" }) };
|
||||
const generatedAt = new Date().toISOString();
|
||||
return { ok: true, json: async () => ({ draft: { ...updated, regeneratedAt: generatedAt, generatedAt, artifactPath: "/tmp/demo.mjs" }, artifact: { draftId: draft1.id, slug: draft1.slug, binPath: "/tmp/demo.mjs", entrypoint: "node", generatedAt } }) };
|
||||
}
|
||||
if (method === "DELETE" && url.endsWith(`/drafts/${draft1.id}`)) return { ok: true, status: 204, json: async () => ({}) };
|
||||
return { ok: false, status: 404, json: async () => ({ error: "Not found" }) };
|
||||
@@ -85,8 +89,8 @@ describe("CliPrintingPressManageView", () => {
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(`/drafts/${draft1.id}`), expect.objectContaining({ method: "PUT" })));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Regenerate/i }));
|
||||
expect(await screen.findByText(/Regenerate stub/)).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: /^Regenerate$/i }));
|
||||
expect(await screen.findByText(/Regenerated at/i)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Delete/i }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(`/drafts/${draft1.id}`), expect.objectContaining({ method: "DELETE" })));
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createCliPrintingPressRoutes } from "../routes/wizard-routes.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
|
||||
function makeDraft(baseUrl: string): ServiceDraft {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: "",
|
||||
name: "Demo",
|
||||
slug: "demo",
|
||||
description: "",
|
||||
baseUrl,
|
||||
transport: "http",
|
||||
endpoints: [{ id: "e1", name: "Ping", method: "GET", path: "/ping" }],
|
||||
credential: { kind: "none" },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function route(method: string, path: string) {
|
||||
const found = createCliPrintingPressRoutes().find((entry) => entry.method === method && entry.path === path);
|
||||
if (!found) throw new Error(`missing route ${method} ${path}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
const servers: Array<{ close: () => void }> = [];
|
||||
afterEach(() => {
|
||||
while (servers.length) servers.pop()?.close();
|
||||
});
|
||||
|
||||
async function startServer(handler: (req: any, res: any) => void): Promise<string> {
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
servers.push(server);
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("Invalid address");
|
||||
return `http://127.0.0.1:${addr.port}`;
|
||||
}
|
||||
|
||||
describe("run routes", () => {
|
||||
it("regenerates and runs successfully", async () => {
|
||||
const baseUrl = await startServer((_req, res) => {
|
||||
res.statusCode = 200;
|
||||
res.end("pong");
|
||||
});
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-run-routes-"));
|
||||
const ctx = { taskStore: { getRootDir: () => rootDir } } as any;
|
||||
|
||||
const createRes = await route("POST", "/drafts").handler({ params: {}, body: makeDraft(baseUrl) }, ctx);
|
||||
const id = (createRes.body as { id: string }).id;
|
||||
|
||||
const regenRes = await route("POST", "/drafts/:id/regenerate").handler({ params: { id } }, ctx);
|
||||
expect(regenRes.status).toBe(200);
|
||||
expect((regenRes.body as any).stub).toBeUndefined();
|
||||
|
||||
const runRes = await route("POST", "/drafts/:id/run").handler({ params: { id }, body: { endpointId: "e1", params: {} } }, ctx);
|
||||
expect(runRes.status).toBe(200);
|
||||
expect((runRes.body as any).stdout).toContain("pong");
|
||||
});
|
||||
|
||||
it("returns validation, 404, 409, and timeout responses", async () => {
|
||||
const baseUrl = await startServer((_req, res) => {
|
||||
setTimeout(() => {
|
||||
res.statusCode = 200;
|
||||
res.end("slow");
|
||||
}, 50);
|
||||
});
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-run-routes-"));
|
||||
const ctx = { taskStore: { getRootDir: () => rootDir } } as any;
|
||||
|
||||
const createRes = await route("POST", "/drafts").handler({ params: {}, body: makeDraft(baseUrl) }, ctx);
|
||||
const id = (createRes.body as { id: string }).id;
|
||||
|
||||
const noArtifact = await route("POST", "/drafts/:id/run").handler({ params: { id }, body: { endpointId: "e1", params: {} } }, ctx);
|
||||
expect(noArtifact.status).toBe(409);
|
||||
|
||||
const badBody = await route("POST", "/drafts/:id/run").handler({ params: { id }, body: { endpointId: "", params: {} } }, ctx);
|
||||
expect(badBody.status).toBe(400);
|
||||
|
||||
const unknown = await route("POST", "/drafts/:id/run").handler({ params: { id: "missing" }, body: { endpointId: "e1", params: {} } }, ctx);
|
||||
expect(unknown.status).toBe(404);
|
||||
|
||||
await route("POST", "/drafts/:id/regenerate").handler({ params: { id } }, ctx);
|
||||
const timeout = await route("POST", "/drafts/:id/run").handler({ params: { id }, body: { endpointId: "e1", params: {}, timeoutMs: 1 } }, ctx);
|
||||
expect(timeout.status).toBe(200);
|
||||
expect((timeout.body as any).timedOut).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runGeneratedCli } from "../generation/runner.js";
|
||||
import type { GeneratedCliArtifact } from "../generation/types.js";
|
||||
|
||||
async function writeFixture(script: string): Promise<{ artifact: GeneratedCliArtifact; root: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), "clipp-runner-"));
|
||||
const binPath = join(root, "fixture.mjs");
|
||||
await writeFile(binPath, script, "utf8");
|
||||
return {
|
||||
root,
|
||||
artifact: {
|
||||
draftId: "draft-1",
|
||||
slug: "fixture",
|
||||
binPath,
|
||||
entrypoint: "node",
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("runGeneratedCli", () => {
|
||||
it("returns success output", async () => {
|
||||
const { artifact, root } = await writeFixture("console.log('ok-output')");
|
||||
const result = await runGeneratedCli({ artifact, endpointId: "x", params: { enabled: true }, cwd: root });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("ok-output");
|
||||
expect(result.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it("captures non-zero exit", async () => {
|
||||
const { artifact, root } = await writeFixture("console.error('bad-output'); process.exit(7)");
|
||||
const result = await runGeneratedCli({ artifact, endpointId: "x", params: {}, cwd: root });
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.stderr).toContain("bad-output");
|
||||
});
|
||||
|
||||
it("captures timeout", async () => {
|
||||
const { artifact, root } = await writeFixture("await new Promise((resolve) => setTimeout(resolve, 500)); console.log('late')");
|
||||
const result = await runGeneratedCli({ artifact, endpointId: "x", params: {}, timeoutMs: 1, cwd: root });
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.exitCode).toBeNull();
|
||||
});
|
||||
|
||||
it("redacts credentials from stdout and argv echo", async () => {
|
||||
const secret = "super-secret-value";
|
||||
const { artifact, root } = await writeFixture("console.log(process.env.CLIPP_CRED_API_KEY)");
|
||||
const result = await runGeneratedCli({ artifact, endpointId: "x", params: { token: secret }, credentials: { api_key: secret }, cwd: root });
|
||||
expect(result.stdout).not.toContain(secret);
|
||||
expect(result.argv.join(" ")).not.toContain(secret);
|
||||
expect(result.stdout).toContain("***");
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe("wizard routes", () => {
|
||||
|
||||
const regenRes = await route("POST", "/drafts/:id/regenerate").handler({ params: { id } }, ctx);
|
||||
expect(regenRes.status).toBe(200);
|
||||
expect((regenRes.body as { stub: boolean }).stub).toBe(true);
|
||||
expect((regenRes.body as { artifact?: { binPath: string } }).artifact?.binPath).toBeTruthy();
|
||||
|
||||
const missingRegenRes = await route("POST", "/drafts/:id/regenerate").handler({ params: { id: "missing" } }, ctx);
|
||||
expect(missingRegenRes.status).toBe(404);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { GenerateCliInput, GeneratedCliArtifact } from "./types.js";
|
||||
|
||||
function buildScript(draftJson: string): string {
|
||||
return `#!/usr/bin/env node
|
||||
const draft = ${draftJson};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = { endpoint: "", params: {} };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const part = argv[i];
|
||||
if (part === "--endpoint") {
|
||||
parsed.endpoint = String(argv[i + 1] ?? "");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (part.startsWith("--")) {
|
||||
const key = part.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || String(next).startsWith("--")) {
|
||||
parsed.params[key] = true;
|
||||
} else {
|
||||
parsed.params[key] = String(next);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function endpointUrl(baseUrl, path) {
|
||||
return new URL(path, baseUrl).toString();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const endpoint = draft.endpoints.find((item) => item.id === args.endpoint);
|
||||
if (!endpoint) {
|
||||
process.stderr.write(\`Unknown endpoint: \${args.endpoint}\\n\`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const method = endpoint.method || "GET";
|
||||
const url = endpointUrl(draft.baseUrl, endpoint.path || "/");
|
||||
const headers = { "content-type": "application/json" };
|
||||
|
||||
if (draft.credential?.kind === "apiKey") {
|
||||
const envValue = process.env[draft.credential.envVar] ?? process.env[\`CLIPP_CRED_\${String(draft.credential.envVar).toUpperCase()}\`];
|
||||
if (envValue) headers[draft.credential.header] = envValue;
|
||||
}
|
||||
if (draft.credential?.kind === "bearerToken") {
|
||||
const token = process.env[draft.credential.envVar] ?? process.env[\`CLIPP_CRED_\${String(draft.credential.envVar).toUpperCase()}\`];
|
||||
if (token) headers.authorization = \`Bearer \${token}\`;
|
||||
}
|
||||
if (draft.credential?.kind === "basicAuth") {
|
||||
const username = process.env[draft.credential.usernameEnvVar] ?? process.env[\`CLIPP_CRED_\${String(draft.credential.usernameEnvVar).toUpperCase()}\`];
|
||||
const password = process.env[draft.credential.passwordEnvVar] ?? process.env[\`CLIPP_CRED_\${String(draft.credential.passwordEnvVar).toUpperCase()}\`];
|
||||
if (username || password) {
|
||||
headers.authorization = "Basic " + Buffer.from(String(username ?? "") + ":" + String(password ?? "")).toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
const body = method === "GET" || method === "DELETE" ? undefined : JSON.stringify(args.params);
|
||||
const response = await fetch(url, { method, headers, body });
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
process.stderr.write(text || \`HTTP \${response.status}\\n\`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(text);
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.message ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
export async function generateCli({ draft, outDir }: GenerateCliInput): Promise<GeneratedCliArtifact> {
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const binPath = join(outDir, `${draft.slug}.mjs`);
|
||||
await writeFile(binPath, buildScript(JSON.stringify(draft)), "utf8");
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
await chmod(binPath, 0o755);
|
||||
}
|
||||
|
||||
return {
|
||||
draftId: draft.id,
|
||||
slug: draft.slug,
|
||||
binPath,
|
||||
entrypoint: "node",
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function redact(text: string, secrets: string[]): string {
|
||||
if (!text) return text;
|
||||
const unique = [...new Set(secrets.filter((secret) => secret && secret.length > 0))].sort((a, b) => b.length - a.length);
|
||||
return unique.reduce((acc, secret) => acc.replace(new RegExp(escapeRegExp(secret), "g"), "***"), text);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { redact } from "./redact.js";
|
||||
import type { GeneratedCliArtifact, RunResult } from "./types.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface RunGeneratedCliInput {
|
||||
artifact: GeneratedCliArtifact;
|
||||
endpointId: string;
|
||||
params: Record<string, string | number | boolean>;
|
||||
credentials?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
function toFlagName(key: string): string {
|
||||
return key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function createArgs(endpointId: string, params: Record<string, string | number | boolean>): string[] {
|
||||
const args: string[] = ["--endpoint", endpointId];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === "boolean") {
|
||||
if (value) args.push(`--${toFlagName(key)}`);
|
||||
continue;
|
||||
}
|
||||
args.push(`--${toFlagName(key)}`, String(value));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function quoteArg(arg: string): string {
|
||||
return JSON.stringify(arg);
|
||||
}
|
||||
|
||||
// Credentials are passed only via env vars: CLIPP_CRED_<UPPER_SNAKE_KEY>.
|
||||
export async function runGeneratedCli({ artifact, endpointId, params, credentials, timeoutMs = 30_000, cwd }: RunGeneratedCliInput): Promise<RunResult> {
|
||||
const args = createArgs(endpointId, params);
|
||||
const argv = [artifact.binPath, ...args];
|
||||
const command = ["node", ...argv].map(quoteArg).join(" ");
|
||||
|
||||
const credEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(credentials ?? {})) {
|
||||
credEnv[`CLIPP_CRED_${key.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}`] = value;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: { ...process.env, ...credEnv },
|
||||
});
|
||||
|
||||
const secrets = Object.values(credentials ?? {});
|
||||
return {
|
||||
stdout: redact(stdout, secrets),
|
||||
stderr: redact(stderr, secrets),
|
||||
exitCode: 0,
|
||||
durationMs: Date.now() - start,
|
||||
timedOut: false,
|
||||
argv: argv.map((part) => redact(part, secrets)),
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as { stdout?: string; stderr?: string; code?: number | null; killed?: boolean; signal?: string };
|
||||
const timedOut = Boolean(err.killed && err.signal === "SIGTERM");
|
||||
const secrets = Object.values(credentials ?? {});
|
||||
return {
|
||||
stdout: redact(err.stdout ?? "", secrets),
|
||||
stderr: redact(err.stderr ?? (timedOut ? "Command timed out" : ""), secrets),
|
||||
exitCode: timedOut ? null : (typeof err.code === "number" ? err.code : null),
|
||||
durationMs: Date.now() - start,
|
||||
timedOut,
|
||||
argv: argv.map((part) => redact(part, secrets)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
|
||||
// Symbol drift note (FN-3764): draft uses `credential` field and optional `params?: string` per endpoint.
|
||||
export type GeneratedCliArtifact = {
|
||||
draftId: string;
|
||||
slug: string;
|
||||
binPath: string;
|
||||
entrypoint: "node" | "npx" | "direct";
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type RunRequest = {
|
||||
endpointId: string;
|
||||
params: Record<string, string | number | boolean>;
|
||||
credentials?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type RunResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
durationMs: number;
|
||||
timedOut: boolean;
|
||||
argv: string[];
|
||||
};
|
||||
|
||||
export type GenerateCliInput = {
|
||||
draft: ServiceDraft;
|
||||
outDir: string;
|
||||
};
|
||||
@@ -34,3 +34,4 @@ const plugin = definePlugin({
|
||||
export default plugin;
|
||||
export { CliPrintingPressWizardView } from "./dashboard-view.js";
|
||||
export { CliPrintingPressManageView } from "./manage-view.js";
|
||||
export { CliPrintingPressTestRunner } from "./run/TestRunnerPanel.js";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { List, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EditDraftModal } from "./manage/EditDraftModal.js";
|
||||
import { useDrafts } from "./manage/useDrafts.js";
|
||||
import { CliPrintingPressTestRunner } from "./run/TestRunnerPanel.js";
|
||||
import type { ServiceDraft } from "./wizard/types.js";
|
||||
import "./manage-view.css";
|
||||
|
||||
@@ -45,7 +46,7 @@ export function CliPrintingPressManageView({ context: _context }: { context?: Pl
|
||||
try {
|
||||
const response = await regenerateDraft(selectedId);
|
||||
setSelectedDraft(response.draft);
|
||||
setStatusMessage(response.message);
|
||||
setStatusMessage(`Regenerated at ${new Date(response.artifact.generatedAt).toLocaleString()}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setStatusMessage(err instanceof Error ? err.message : "Failed to regenerate draft");
|
||||
@@ -107,6 +108,7 @@ export function CliPrintingPressManageView({ context: _context }: { context?: Pl
|
||||
<button className="btn" onClick={() => void onRegenerate()}><RefreshCw /> Regenerate</button>
|
||||
<button className="btn btn-danger" onClick={() => void onDelete()}><Trash2 /> Delete</button>
|
||||
</div>
|
||||
<CliPrintingPressTestRunner draftId={selectedDraft.id} draft={selectedDraft} />
|
||||
</>
|
||||
) : <p>Select a draft to inspect details.</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { GeneratedCliArtifact } from "../generation/types.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
|
||||
export interface DraftListItem {
|
||||
@@ -57,10 +58,10 @@ export function useDrafts() {
|
||||
return await response.json() as ServiceDraft;
|
||||
}, []);
|
||||
|
||||
const regenerateDraft = useCallback(async (id: string): Promise<{ draft: ServiceDraft; stub: boolean; message: string }> => {
|
||||
const regenerateDraft = useCallback(async (id: string): Promise<{ draft: ServiceDraft; artifact: GeneratedCliArtifact }> => {
|
||||
const response = await fetch(`${BASE_PATH}/${id}/regenerate`, { method: "POST" });
|
||||
if (!response.ok) throw new Error(await parseError(response, "Failed to regenerate draft"));
|
||||
return await response.json() as { draft: ServiceDraft; stub: boolean; message: string };
|
||||
return await response.json() as { draft: ServiceDraft; artifact: GeneratedCliArtifact };
|
||||
}, []);
|
||||
|
||||
const deleteDraft = useCallback(async (id: string): Promise<void> => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResult } from "@fusion/core";
|
||||
import { createDraftStore, NotFoundError } from "../storage/draft-store.js";
|
||||
import { generateCli } from "../generation/generator.js";
|
||||
import { runGeneratedCli } from "../generation/runner.js";
|
||||
import type { GeneratedCliArtifact, RunRequest } from "../generation/types.js";
|
||||
import { createDraftStore, getArtifactDir, NotFoundError } from "../storage/draft-store.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
import { validateDraft } from "../wizard/validation.js";
|
||||
|
||||
@@ -9,9 +12,53 @@ interface RouteRequest {
|
||||
}
|
||||
|
||||
function asRequest(req: unknown): RouteRequest { return req as RouteRequest; }
|
||||
|
||||
function ok(body: unknown, status = 200): PluginRouteResult { return { status, body }; }
|
||||
|
||||
function asArtifact(draft: ServiceDraft): GeneratedCliArtifact | null {
|
||||
if (!draft.artifactPath || !draft.generatedAt) return null;
|
||||
return {
|
||||
draftId: draft.id,
|
||||
slug: draft.slug,
|
||||
binPath: draft.artifactPath,
|
||||
entrypoint: "node",
|
||||
generatedAt: draft.generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function isPrimitive(value: unknown): value is string | number | boolean {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
||||
}
|
||||
|
||||
function validateRunRequest(body: unknown): { ok: true; value: RunRequest } | { ok: false; error: string } {
|
||||
if (!body || typeof body !== "object") return { ok: false, error: "Request body is required" };
|
||||
const candidate = body as Record<string, unknown>;
|
||||
if (typeof candidate.endpointId !== "string" || !candidate.endpointId.trim()) return { ok: false, error: "endpointId is required" };
|
||||
if (!candidate.params || typeof candidate.params !== "object" || Array.isArray(candidate.params)) return { ok: false, error: "params must be an object" };
|
||||
for (const value of Object.values(candidate.params as Record<string, unknown>)) {
|
||||
if (!isPrimitive(value)) return { ok: false, error: "params values must be primitives" };
|
||||
}
|
||||
if (candidate.credentials !== undefined) {
|
||||
if (!candidate.credentials || typeof candidate.credentials !== "object" || Array.isArray(candidate.credentials)) return { ok: false, error: "credentials must be an object" };
|
||||
for (const value of Object.values(candidate.credentials as Record<string, unknown>)) {
|
||||
if (typeof value !== "string") return { ok: false, error: "credentials values must be strings" };
|
||||
}
|
||||
}
|
||||
if (candidate.timeoutMs !== undefined) {
|
||||
if (!Number.isFinite(candidate.timeoutMs) || !Number.isInteger(candidate.timeoutMs) || (candidate.timeoutMs as number) <= 0 || (candidate.timeoutMs as number) > 300_000) {
|
||||
return { ok: false, error: "timeoutMs must be an integer between 1 and 300000" };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
endpointId: candidate.endpointId,
|
||||
params: candidate.params as Record<string, string | number | boolean>,
|
||||
credentials: candidate.credentials as Record<string, string> | undefined,
|
||||
timeoutMs: candidate.timeoutMs as number | undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
@@ -68,14 +115,58 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
path: "/drafts/:id/regenerate",
|
||||
handler: async (req, ctx: PluginContext) => {
|
||||
const request = asRequest(req);
|
||||
const projectRoot = ctx.taskStore.getRootDir();
|
||||
const store = createDraftStore({ rootDir: projectRoot });
|
||||
const existing = await store.get(request.params.id);
|
||||
if (!existing) return ok({ error: "Draft not found" }, 404);
|
||||
|
||||
const artifact = await generateCli({ draft: existing, outDir: getArtifactDir(existing.id, projectRoot) });
|
||||
const draft = await store.update(request.params.id, {
|
||||
regeneratedAt: artifact.generatedAt,
|
||||
generatedAt: artifact.generatedAt,
|
||||
artifactPath: artifact.binPath,
|
||||
});
|
||||
return ok({ draft, artifact });
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/drafts/:id/run",
|
||||
handler: async (req, ctx: PluginContext) => {
|
||||
const request = asRequest(req);
|
||||
const parsed = validateRunRequest(request.body);
|
||||
if (!parsed.ok) return ok({ error: parsed.error }, 400);
|
||||
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
try {
|
||||
const draft = await store.update(request.params.id, { regeneratedAt: new Date().toISOString() });
|
||||
return ok({ draft, stub: true, message: "Regenerate stub — full generation lands in FN-3765/FN-3767" });
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return ok({ error: "Draft not found" }, 404);
|
||||
throw error;
|
||||
}
|
||||
const draft = await store.get(request.params.id);
|
||||
if (!draft) return ok({ error: "Draft not found" }, 404);
|
||||
|
||||
const artifact = asArtifact(draft);
|
||||
if (!artifact) return ok({ error: "Draft has not been generated yet" }, 409);
|
||||
|
||||
const endpointExists = draft.endpoints.some((endpoint) => endpoint.id === parsed.value.endpointId);
|
||||
if (!endpointExists) return ok({ error: "Endpoint not found" }, 400);
|
||||
|
||||
const result = await runGeneratedCli({
|
||||
artifact,
|
||||
endpointId: parsed.value.endpointId,
|
||||
params: parsed.value.params,
|
||||
credentials: parsed.value.credentials,
|
||||
timeoutMs: parsed.value.timeoutMs,
|
||||
cwd: ctx.taskStore.getRootDir(),
|
||||
});
|
||||
return ok(result, 200);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/drafts/:id/artifact",
|
||||
handler: async (req, ctx: PluginContext) => {
|
||||
const request = asRequest(req);
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
const draft = await store.get(request.params.id);
|
||||
const artifact = draft ? asArtifact(draft) : null;
|
||||
return artifact ? ok({ artifact }) : ok({ error: "Artifact not found" }, 404);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
.clipp-test-runner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.clipp-test-runner-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.clipp-test-runner-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.clipp-test-runner-header p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.clipp-test-runner-slug {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.clipp-test-runner-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.clipp-test-runner-form,
|
||||
.clipp-test-runner-output {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.clipp-test-runner-help {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.clipp-test-runner-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.clipp-test-runner-pre {
|
||||
margin: 0;
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
background: var(--surface);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.clipp-test-runner-pre-stderr {
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.clipp-test-runner-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { AlertTriangle, CheckCircle2, Play, RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { RunResult } from "../generation/types.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
import { useRunGeneratedCli } from "./useRunGeneratedCli.js";
|
||||
import "./TestRunnerPanel.css";
|
||||
|
||||
export function CliPrintingPressTestRunner({ draftId, draft }: { draftId: string; draft: ServiceDraft }) {
|
||||
const { regenerate, run } = useRunGeneratedCli();
|
||||
const [selectedEndpointId, setSelectedEndpointId] = useState(draft.endpoints[0]?.id ?? "");
|
||||
const [params, setParams] = useState<Record<string, string | number | boolean>>({});
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>({});
|
||||
const [result, setResult] = useState<RunResult | null>(null);
|
||||
const [generatedAt, setGeneratedAt] = useState<string | undefined>(draft.generatedAt ?? draft.regeneratedAt);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const endpoint = useMemo(() => draft.endpoints.find((item) => item.id === selectedEndpointId) ?? draft.endpoints[0], [draft.endpoints, selectedEndpointId]);
|
||||
const paramKeys = useMemo(() => (endpoint?.params ?? "").split(",").map((item) => item.trim()).filter(Boolean), [endpoint?.params]);
|
||||
|
||||
async function onRegenerate() {
|
||||
setError(null);
|
||||
const response = await regenerate(draftId);
|
||||
setGeneratedAt(response.artifact.generatedAt);
|
||||
}
|
||||
|
||||
async function onRun() {
|
||||
if (!endpoint) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
setResult(await run(draftId, { endpointId: endpoint.id, params, credentials }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Run failed");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const status = running ? "running" : (result?.timedOut || (typeof result?.exitCode === "number" && result.exitCode !== 0) ? "error" : (result ? "success" : "idle"));
|
||||
|
||||
return (
|
||||
<section className="card clipp-test-runner">
|
||||
<header className="clipp-test-runner-header">
|
||||
<div>
|
||||
<h4>{draft.name} <span className="clipp-test-runner-slug">({draft.slug})</span></h4>
|
||||
<p>Generated: {generatedAt ? new Date(generatedAt).toLocaleString() : "Not generated"}</p>
|
||||
</div>
|
||||
<button className="btn btn-icon" onClick={() => void onRegenerate()} aria-label="Regenerate draft">
|
||||
<RefreshCw />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="clipp-test-runner-grid">
|
||||
<div className="clipp-test-runner-form">
|
||||
<label htmlFor={`clipp-endpoint-${draftId}`}>Endpoint</label>
|
||||
<select id={`clipp-endpoint-${draftId}`} className="select" value={endpoint?.id ?? ""} onChange={(event) => setSelectedEndpointId(event.target.value)}>
|
||||
{draft.endpoints.map((item) => <option key={item.id} value={item.id}>{item.method} {item.path}</option>)}
|
||||
</select>
|
||||
|
||||
{paramKeys.map((key) => (
|
||||
<div key={key} className="form-group">
|
||||
<label htmlFor={`clipp-param-${key}`}>{key}</label>
|
||||
<input id={`clipp-param-${key}`} className="input" value={String(params[key] ?? "")} onChange={(event) => setParams((prev) => ({ ...prev, [key]: event.target.value }))} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="clipp-test-runner-credentials">
|
||||
<label>Credentials</label>
|
||||
<input className="input" type="password" placeholder="api_key" value={credentials.api_key ?? ""} onChange={(event) => setCredentials((prev) => ({ ...prev, api_key: event.target.value }))} />
|
||||
<p className="clipp-test-runner-help">Credential values are used only for this run and are not persisted.</p>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary" disabled={running} onClick={() => void onRun()}><Play /> Run</button>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="clipp-test-runner-output">
|
||||
<div className="clipp-test-runner-status">
|
||||
<span className="status-dot" />
|
||||
{status === "running" ? <><AlertTriangle /> Running…</> : null}
|
||||
{status === "success" ? <><CheckCircle2 /> Success</> : null}
|
||||
{status === "error" ? <><AlertTriangle /> Failed</> : null}
|
||||
{result ? <span>{result.durationMs}ms</span> : null}
|
||||
</div>
|
||||
{result ? (
|
||||
<>
|
||||
<pre className="clipp-test-runner-pre">$ node {result.argv.join(" ")}</pre>
|
||||
<pre className="clipp-test-runner-pre">{result.stdout || "(no stdout)"}</pre>
|
||||
<pre className="clipp-test-runner-pre clipp-test-runner-pre-stderr">{result.stderr || "(no stderr)"}</pre>
|
||||
</>
|
||||
) : <p>No run output yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { GeneratedCliArtifact, RunRequest, RunResult } from "../generation/types.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
|
||||
const BASE_PATH = "/api/plugins/cli-printing-press/drafts";
|
||||
|
||||
async function parseJson<T>(response: Response): Promise<T> {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = (body as { error?: string }).error ?? "Request failed";
|
||||
throw new Error(error);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export function useRunGeneratedCli() {
|
||||
async function regenerate(id: string, signal?: AbortSignal): Promise<{ draft: ServiceDraft; artifact: GeneratedCliArtifact }> {
|
||||
const response = await fetch(`${BASE_PATH}/${id}/regenerate`, { method: "POST", signal });
|
||||
return parseJson<{ draft: ServiceDraft; artifact: GeneratedCliArtifact }>(response);
|
||||
}
|
||||
|
||||
async function run(id: string, payload: RunRequest, signal?: AbortSignal): Promise<RunResult> {
|
||||
const response = await fetch(`${BASE_PATH}/${id}/run`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
return parseJson<RunResult>(response);
|
||||
}
|
||||
|
||||
async function getArtifact(id: string, signal?: AbortSignal): Promise<{ artifact: GeneratedCliArtifact }> {
|
||||
const response = await fetch(`${BASE_PATH}/${id}/artifact`, { signal });
|
||||
return parseJson<{ artifact: GeneratedCliArtifact }>(response);
|
||||
}
|
||||
|
||||
return { regenerate, run, getArtifact };
|
||||
}
|
||||
@@ -24,6 +24,10 @@ function mergeDraft(existing: ServiceDraft, patch: Partial<ServiceDraft>): Servi
|
||||
};
|
||||
}
|
||||
|
||||
export function getArtifactDir(id: string, projectRoot: string): string {
|
||||
return join(projectRoot, ".fusion", "plugins", "cli-printing-press", "generated", id);
|
||||
}
|
||||
|
||||
export function createDraftStore({ rootDir }: { rootDir: string }) {
|
||||
const draftsDir = join(rootDir, ".fusion", "plugins", "cli-printing-press", "drafts");
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface ServiceDraft {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
regeneratedAt?: string;
|
||||
generatedAt?: string;
|
||||
artifactPath?: string;
|
||||
}
|
||||
|
||||
export type WizardStep = "basics" | "transport" | "endpoints" | "credentials" | "review";
|
||||
|
||||
Reference in New Issue
Block a user