feat(FN-3766): add cli press sqlite store and credential helpers
Implements SQLite-based storage and credential management for the CLI printing press plugin, including type definitions, a persistent `cli-press-store`, and credential helpers, with updated routes, tests, and documentation. Fusion-Task-Id: FN-3766
This commit is contained in:
@@ -1,79 +1,56 @@
|
||||
# fusion-plugin-cli-printing-press
|
||||
|
||||
Bundled first-party Fusion plugin that adds a plugin-owned dashboard wizard for drafting an external service CLI definition.
|
||||
Bundled first-party Fusion plugin for generating and managing service CLIs.
|
||||
|
||||
## v1 scope (FN-3763 + FN-3764 + FN-3765)
|
||||
## Storage & Config Model
|
||||
|
||||
- Provides two dashboard views:
|
||||
- **Create Service CLI** (`viewId: wizard`)
|
||||
- **Manage Service CLIs** (`viewId: manage`)
|
||||
- Wizard collects service basics, HTTP transport details, endpoints, and non-OAuth credential placeholders
|
||||
- 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`
|
||||
### Tables
|
||||
|
||||
## Run / Test panel (FN-3765)
|
||||
- `cli_press_services`: service metadata (`id`, `slug`, `displayName`, `description`, `baseUrl`, `sourceKind`, `sourceRef`, timestamps)
|
||||
- `cli_press_cli_specs`: generated/spec inputs per service (`id`, `serviceId`, `name`, `version`, `generatorVersion`, `specJson`, `generatedAt`, `status`, `lastGenerationError`, timestamps)
|
||||
- `cli_press_artifacts`: generated artifact metadata (`id`, `cliSpecId`, `kind`, `path`, `executable`, `checksum`, `sizeBytes`, timestamps)
|
||||
- `cli_press_credentials`: non-OAuth credentials (`id`, `serviceId`, `name`, `kind`, `value` envelope, `placement`, timestamps)
|
||||
- `cli_press_service_settings`: service-scoped key/value settings (`id`, `serviceId`, `key`, `value`, `scope`, timestamps)
|
||||
|
||||
Each draft in **Manage Service CLIs** includes a **Run / Test** panel:
|
||||
All IDs are UUIDv4-based with prefixes: `svc_`, `cli_`, `art_`, `cred_`, `set_`. Timestamps are ISO-8601 strings.
|
||||
|
||||
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.
|
||||
### Exported Types
|
||||
|
||||
The panel shows:
|
||||
- status, duration, and redacted argv echo
|
||||
- stdout and stderr
|
||||
- exitCode and timeout state (`timedOut`)
|
||||
- `Service`: canonical external-service record
|
||||
- `CliSpec`: persisted cli-printing-press spec/generation state
|
||||
- `CliArtifact`: artifact file metadata (path stored relative to `<projectRoot>/.fusion/`)
|
||||
- `Credential`: persisted secret envelope + placement metadata
|
||||
- `CredentialKind`: closed union of non-OAuth kinds (`api_key`, `bearer_token`, `basic_auth`, `header`, `query_param`, `env_var`)
|
||||
- `CredentialPlacement`: discriminated placement union
|
||||
- `ServiceSetting`: service-level setting entry (`runtime` | `wizard` | `metadata`)
|
||||
- `OAuthNotSupportedError`: thrown when oauth/oauth2 is passed
|
||||
- `InvalidCredentialPlacementError`: thrown on kind/placement mismatch or invalid `api_key` placement
|
||||
|
||||
### Credential contract
|
||||
### Credential placement union
|
||||
|
||||
- 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.
|
||||
- `{ kind: "header", header: string }`
|
||||
- `{ kind: "query_param", queryParam: string }`
|
||||
- `{ kind: "env_var", envVar: string }`
|
||||
- `{ kind: "bearer_token", header: string }`
|
||||
- `{ kind: "api_key", header?: string, queryParam?: string }` (exactly one required)
|
||||
- `{ kind: "basic_auth", header: string }`
|
||||
|
||||
## Plugin API routes
|
||||
### Credential encoding/materialization
|
||||
|
||||
Plugin views call host-prefixed plugin routes under `/api/plugins/cli-printing-press/`:
|
||||
- Values are stored as `{ encoding: "base64", value: string }` via `encodeCredentialValue`/`decodeCredentialValue`.
|
||||
- `applyCredentialToRequest` materializes credentials into `{ headers, query, env }` and rejects OAuth at runtime.
|
||||
|
||||
- `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
|
||||
### OAuth policy (deferred)
|
||||
|
||||
### Run endpoint behavior
|
||||
OAuth/OAuth2 flows are intentionally excluded from v1. Any `oauth`/`oauth2` kind is rejected by store-layer and helper-layer guards with `OAuthNotSupportedError`. Follow-up remains tracked in **FN-3762**.
|
||||
|
||||
`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)
|
||||
### Artifact path convention
|
||||
|
||||
Validation and state errors return:
|
||||
- `400` invalid body/params/timeout
|
||||
- `404` unknown draft id
|
||||
- `409` draft exists but has not been generated yet
|
||||
Generated artifacts are expected under:
|
||||
`<projectRoot>/.fusion/plugins/cli-printing-press/artifacts/<serviceId>/<specId>/<artifactFile>`
|
||||
|
||||
## Provisional architecture assumptions (pending FN-3762/FN-3766)
|
||||
`CliArtifact.path` stores the path relative to `<projectRoot>/.fusion/`.
|
||||
|
||||
The following choices are intentionally provisional and may be revised by architecture/storage follow-up work:
|
||||
### Deletions and filesystem cleanup
|
||||
|
||||
- `PluginContext` usage pattern in route handlers
|
||||
- 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**
|
||||
- Canonical storage migration (replace JSON stash): **FN-3766**
|
||||
- Runtime exposure/integration: **FN-3767**
|
||||
- Workflow-step exposure: **FN-3768**
|
||||
- Persistent run history: deferred (response-only run results in FN-3765)
|
||||
`deleteService`, `deleteSpec`, and `deleteArtifact` remove DB records. v1 intentionally does **not** remove artifact files from disk; cleanup is deferred to **FN-3767**.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Database } from "@fusion/core";
|
||||
import { createCliPrintingPressRoutes } from "../routes/wizard-routes.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
|
||||
@@ -49,7 +50,9 @@ describe("run routes", () => {
|
||||
res.end("pong");
|
||||
});
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-run-routes-"));
|
||||
const ctx = { taskStore: { getRootDir: () => rootDir } } as any;
|
||||
const db = new Database(join(rootDir, ".fusion"), { inMemory: true });
|
||||
db.init();
|
||||
const ctx = { taskStore: { getRootDir: () => rootDir, getDatabase: () => db } } as any;
|
||||
|
||||
const createRes = await route("POST", "/drafts").handler({ params: {}, body: makeDraft(baseUrl) }, ctx);
|
||||
const id = (createRes.body as { id: string }).id;
|
||||
@@ -71,7 +74,9 @@ describe("run routes", () => {
|
||||
}, 50);
|
||||
});
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-run-routes-"));
|
||||
const ctx = { taskStore: { getRootDir: () => rootDir } } as any;
|
||||
const db = new Database(join(rootDir, ".fusion"), { inMemory: true });
|
||||
db.init();
|
||||
const ctx = { taskStore: { getRootDir: () => rootDir, getDatabase: () => db } } as any;
|
||||
|
||||
const createRes = await route("POST", "/drafts").handler({ params: {}, body: makeDraft(baseUrl) }, ctx);
|
||||
const id = (createRes.body as { id: string }).id;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Database } from "@fusion/core";
|
||||
import { createCliPrintingPressRoutes } from "../routes/wizard-routes";
|
||||
import type { ServiceDraft } from "../wizard/types";
|
||||
|
||||
@@ -19,7 +20,9 @@ function route(method: string, path: string) {
|
||||
describe("wizard routes", () => {
|
||||
it("handles create/get/delete lifecycle", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-routes-"));
|
||||
const ctx = { taskStore: { getRootDir: () => rootDir } } as any;
|
||||
const db = new Database(join(rootDir, ".fusion"), { inMemory: true });
|
||||
db.init();
|
||||
const ctx = { taskStore: { getRootDir: () => rootDir, getDatabase: () => db } } as any;
|
||||
|
||||
const createRes = await route("POST", "/drafts").handler({ params: {}, body: makeDraft() }, ctx);
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { createCliPrintingPressRoutes } from "./routes/wizard-routes.js";
|
||||
import { ensureCliPressSchema } from "./store/cli-press-store.js";
|
||||
|
||||
const plugin = definePlugin({
|
||||
manifest: {
|
||||
@@ -9,7 +10,9 @@ const plugin = definePlugin({
|
||||
description: "Guided wizard for drafting external service CLI definitions",
|
||||
},
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
hooks: {
|
||||
onSchemaInit: ensureCliPressSchema,
|
||||
},
|
||||
routes: createCliPrintingPressRoutes(),
|
||||
dashboardViews: [
|
||||
{
|
||||
@@ -35,3 +38,5 @@ export default plugin;
|
||||
export { CliPrintingPressWizardView } from "./dashboard-view.js";
|
||||
export { CliPrintingPressManageView } from "./manage-view.js";
|
||||
export { CliPrintingPressTestRunner } from "./run/TestRunnerPanel.js";
|
||||
export { createCliPressStore, ensureCliPressSchema } from "./store/cli-press-store.js";
|
||||
export * from "./store/cli-press-types.js";
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { PluginContext, PluginRouteDefinition, PluginRouteResult } from "@f
|
||||
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 { createCliPressStore } from "../store/cli-press-store.js";
|
||||
import type { CliSpec, Service } from "../store/cli-press-types.js";
|
||||
import type { ServiceDraft } from "../wizard/types.js";
|
||||
import { validateDraft } from "../wizard/validation.js";
|
||||
|
||||
@@ -25,6 +26,32 @@ function asArtifact(draft: ServiceDraft): GeneratedCliArtifact | null {
|
||||
};
|
||||
}
|
||||
|
||||
function getArtifactDir(id: string, projectRoot: string): string {
|
||||
return `${projectRoot}/.fusion/plugins/cli-printing-press/artifacts/${id}`;
|
||||
}
|
||||
|
||||
function getStore(ctx: PluginContext) {
|
||||
return createCliPressStore(ctx.taskStore.getDatabase());
|
||||
}
|
||||
|
||||
function toDraft(service: Service, spec: CliSpec | undefined, endpoints: ServiceDraft["endpoints"]): ServiceDraft {
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.displayName,
|
||||
slug: service.slug,
|
||||
description: service.description ?? "",
|
||||
baseUrl: service.baseUrl,
|
||||
transport: "http",
|
||||
endpoints,
|
||||
credential: { kind: "none" },
|
||||
createdAt: service.createdAt,
|
||||
updatedAt: service.updatedAt,
|
||||
generatedAt: spec?.generatedAt,
|
||||
regeneratedAt: spec?.generatedAt,
|
||||
artifactPath: spec?.status === "generated" ? spec.specJson : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isPrimitive(value: unknown): value is string | number | boolean {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
||||
}
|
||||
@@ -69,8 +96,27 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
const draft = request.body as ServiceDraft;
|
||||
const result = validateDraft(draft);
|
||||
if (!result.ok) return { status: 400, body: { error: Object.values(result.errors)[0] ?? "Validation failed", errors: result.errors } };
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
const created = await store.create(draft);
|
||||
const store = getStore(ctx);
|
||||
const createdService = store.createService({
|
||||
slug: draft.slug,
|
||||
displayName: draft.name,
|
||||
description: draft.description,
|
||||
baseUrl: draft.baseUrl,
|
||||
sourceKind: "manual",
|
||||
sourceRef: undefined,
|
||||
});
|
||||
const createdSpec = store.createSpec({
|
||||
serviceId: createdService.id,
|
||||
name: `${draft.slug}-cli`,
|
||||
version: "0.1.0",
|
||||
generatorVersion: "cli-printing-press",
|
||||
specJson: JSON.stringify(draft),
|
||||
status: "draft",
|
||||
generatedAt: undefined,
|
||||
lastGenerationError: undefined,
|
||||
});
|
||||
store.setSetting({ serviceId: createdService.id, key: "endpoints", value: JSON.stringify(draft.endpoints), scope: "wizard" });
|
||||
const created = toDraft(createdService, createdSpec, draft.endpoints);
|
||||
return ok(created, 201);
|
||||
},
|
||||
},
|
||||
@@ -78,8 +124,14 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
method: "GET",
|
||||
path: "/drafts",
|
||||
handler: async (_req, ctx: PluginContext) => {
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
return ok(await store.list());
|
||||
const store = getStore(ctx);
|
||||
const drafts = store.listServices().map((service) => ({
|
||||
id: service.id,
|
||||
name: service.displayName,
|
||||
slug: service.slug,
|
||||
updatedAt: service.updatedAt,
|
||||
}));
|
||||
return ok(drafts);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -87,9 +139,13 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
path: "/drafts/:id",
|
||||
handler: async (req, ctx: PluginContext) => {
|
||||
const request = asRequest(req);
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
const draft = await store.get(request.params.id);
|
||||
return draft ? ok(draft) : ok({ error: "Draft not found" }, 404);
|
||||
const store = getStore(ctx);
|
||||
const service = store.getService(request.params.id);
|
||||
if (!service) return ok({ error: "Draft not found" }, 404);
|
||||
const spec = store.listSpecs(service.id)[0];
|
||||
const endpointsSetting = store.listSettings(service.id).find((entry) => entry.key === "endpoints" && entry.scope === "wizard");
|
||||
const endpoints = endpointsSetting ? (JSON.parse(endpointsSetting.value) as ServiceDraft["endpoints"]) : [];
|
||||
return ok(toDraft(service, spec, endpoints));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -100,14 +156,30 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
const draft = request.body as ServiceDraft;
|
||||
const result = validateDraft(draft);
|
||||
if (!result.ok) return { status: 400, body: { error: Object.values(result.errors)[0] ?? "Validation failed", errors: result.errors } };
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
try {
|
||||
const updated = await store.update(request.params.id, draft);
|
||||
return ok(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return ok({ error: "Draft not found" }, 404);
|
||||
throw error;
|
||||
}
|
||||
const store = getStore(ctx);
|
||||
const service = store.getService(request.params.id);
|
||||
if (!service) return ok({ error: "Draft not found" }, 404);
|
||||
const updatedService = store.updateService(service.id, {
|
||||
displayName: draft.name,
|
||||
description: draft.description,
|
||||
baseUrl: draft.baseUrl,
|
||||
sourceKind: "manual",
|
||||
});
|
||||
const existingSpec = store.listSpecs(service.id)[0];
|
||||
const updatedSpec = existingSpec
|
||||
? store.updateSpec(existingSpec.id, { specJson: JSON.stringify(draft), status: "draft", lastGenerationError: undefined })
|
||||
: store.createSpec({
|
||||
serviceId: service.id,
|
||||
name: `${draft.slug}-cli`,
|
||||
version: "0.1.0",
|
||||
generatorVersion: "cli-printing-press",
|
||||
specJson: JSON.stringify(draft),
|
||||
status: "draft",
|
||||
generatedAt: undefined,
|
||||
lastGenerationError: undefined,
|
||||
});
|
||||
store.setSetting({ serviceId: service.id, key: "endpoints", value: JSON.stringify(draft.endpoints), scope: "wizard" });
|
||||
return ok(toDraft(updatedService, updatedSpec, draft.endpoints));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -116,17 +188,28 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
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,
|
||||
const store = getStore(ctx);
|
||||
const service = store.getService(request.params.id);
|
||||
if (!service) return ok({ error: "Draft not found" }, 404);
|
||||
const spec = store.listSpecs(service.id)[0];
|
||||
if (!spec) return ok({ error: "Draft not found" }, 404);
|
||||
const draft = JSON.parse(spec.specJson) as ServiceDraft;
|
||||
const artifact = await generateCli({ draft, outDir: getArtifactDir(draft.id, projectRoot) });
|
||||
const updatedSpec = store.updateSpec(spec.id, {
|
||||
specJson: JSON.stringify({ ...draft, regeneratedAt: artifact.generatedAt, generatedAt: artifact.generatedAt, artifactPath: artifact.binPath }),
|
||||
generatedAt: artifact.generatedAt,
|
||||
artifactPath: artifact.binPath,
|
||||
status: "generated",
|
||||
lastGenerationError: undefined,
|
||||
});
|
||||
return ok({ draft, artifact });
|
||||
store.createArtifact({
|
||||
cliSpecId: spec.id,
|
||||
kind: "script",
|
||||
path: artifact.binPath.replace(`${projectRoot}/.fusion/`, ""),
|
||||
executable: true,
|
||||
checksum: undefined,
|
||||
sizeBytes: undefined,
|
||||
});
|
||||
return ok({ draft: JSON.parse(updatedSpec.specJson) as ServiceDraft, artifact });
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -137,10 +220,12 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
const parsed = validateRunRequest(request.body);
|
||||
if (!parsed.ok) return ok({ error: parsed.error }, 400);
|
||||
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
const draft = await store.get(request.params.id);
|
||||
if (!draft) return ok({ error: "Draft not found" }, 404);
|
||||
|
||||
const store = getStore(ctx);
|
||||
const service = store.getService(request.params.id);
|
||||
if (!service) return ok({ error: "Draft not found" }, 404);
|
||||
const spec = store.listSpecs(service.id)[0];
|
||||
if (!spec) return ok({ error: "Draft not found" }, 404);
|
||||
const draft = JSON.parse(spec.specJson) as ServiceDraft;
|
||||
const artifact = asArtifact(draft);
|
||||
if (!artifact) return ok({ error: "Draft has not been generated yet" }, 409);
|
||||
|
||||
@@ -163,9 +248,13 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
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;
|
||||
const store = getStore(ctx);
|
||||
const service = store.getService(request.params.id);
|
||||
if (!service) return ok({ error: "Artifact not found" }, 404);
|
||||
const spec = store.listSpecs(service.id)[0];
|
||||
if (!spec) return ok({ error: "Artifact not found" }, 404);
|
||||
const draft = JSON.parse(spec.specJson) as ServiceDraft;
|
||||
const artifact = asArtifact(draft);
|
||||
return artifact ? ok({ artifact }) : ok({ error: "Artifact not found" }, 404);
|
||||
},
|
||||
},
|
||||
@@ -174,8 +263,8 @@ export function createCliPrintingPressRoutes(): PluginRouteDefinition[] {
|
||||
path: "/drafts/:id",
|
||||
handler: async (req, ctx: PluginContext) => {
|
||||
const request = asRequest(req);
|
||||
const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() });
|
||||
await store.delete(request.params.id);
|
||||
const store = getStore(ctx);
|
||||
store.deleteService(request.params.id);
|
||||
return { status: 204 };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Database } from "@fusion/core";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createCliPressStore, ensureCliPressSchema } from "../cli-press-store.js";
|
||||
import { encodeCredentialValue } from "../credentials.js";
|
||||
|
||||
describe("cli-press-store", () => {
|
||||
let rootDir: string;
|
||||
let db: Database;
|
||||
let store: ReturnType<typeof createCliPressStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "cli-press-store-"));
|
||||
db = new Database(join(rootDir, ".fusion"), { inMemory: true });
|
||||
db.init();
|
||||
ensureCliPressSchema(db);
|
||||
ensureCliPressSchema(db);
|
||||
store = createCliPressStore(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates schema idempotently and runs full CRUD", () => {
|
||||
const service = store.createService({
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
description: "Demo service",
|
||||
baseUrl: "https://example.com",
|
||||
sourceKind: "manual",
|
||||
sourceRef: "wizard",
|
||||
});
|
||||
expect(service.id).toMatch(/^svc_/);
|
||||
|
||||
const updatedService = store.updateService(service.id, { displayName: "Demo Updated" });
|
||||
expect(updatedService.displayName).toBe("Demo Updated");
|
||||
|
||||
const spec = store.createSpec({
|
||||
serviceId: service.id,
|
||||
name: "demo-cli",
|
||||
version: "0.1.0",
|
||||
generatorVersion: "1.0.0",
|
||||
specJson: JSON.stringify({ hello: "world" }),
|
||||
status: "draft",
|
||||
generatedAt: undefined,
|
||||
lastGenerationError: undefined,
|
||||
});
|
||||
expect(store.getSpec(spec.id)?.specJson).toBe(JSON.stringify({ hello: "world" }));
|
||||
|
||||
const updatedSpec = store.updateSpec(spec.id, { status: "generated" });
|
||||
expect(updatedSpec.status).toBe("generated");
|
||||
|
||||
const artifact = store.createArtifact({
|
||||
cliSpecId: spec.id,
|
||||
kind: "script",
|
||||
path: "plugins/cli-printing-press/artifacts/demo.sh",
|
||||
executable: true,
|
||||
checksum: "abc",
|
||||
sizeBytes: 42,
|
||||
});
|
||||
expect(artifact.id).toMatch(/^art_/);
|
||||
|
||||
const cred = store.createCredential({
|
||||
serviceId: service.id,
|
||||
name: "api",
|
||||
kind: "api_key",
|
||||
value: encodeCredentialValue("secret"),
|
||||
placement: { kind: "api_key", header: "X-API-Key" },
|
||||
});
|
||||
expect(cred.id).toMatch(/^cred_/);
|
||||
|
||||
const setting = store.setSetting({
|
||||
serviceId: service.id,
|
||||
key: "region",
|
||||
value: "us-east-1",
|
||||
scope: "runtime",
|
||||
});
|
||||
expect(setting.id).toMatch(/^set_/);
|
||||
|
||||
expect(store.listServices()).toHaveLength(1);
|
||||
expect(store.listSpecs(service.id)).toHaveLength(1);
|
||||
expect(store.listArtifacts(spec.id)).toHaveLength(1);
|
||||
expect(store.listCredentials(service.id)).toHaveLength(1);
|
||||
expect(store.listSettings(service.id)).toHaveLength(1);
|
||||
|
||||
store.deleteService(service.id);
|
||||
expect(store.listServices()).toHaveLength(0);
|
||||
expect(store.listSpecs(service.id)).toHaveLength(0);
|
||||
expect(store.listCredentials(service.id)).toHaveLength(0);
|
||||
expect(store.listSettings(service.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects oauth and invalid placement", () => {
|
||||
const service = store.createService({
|
||||
slug: "oauth-demo",
|
||||
displayName: "OAuth Demo",
|
||||
description: "",
|
||||
baseUrl: "https://example.com",
|
||||
sourceKind: "manual",
|
||||
sourceRef: "wizard",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
store.createCredential({
|
||||
serviceId: service.id,
|
||||
name: "bad",
|
||||
kind: "oauth" as never,
|
||||
value: encodeCredentialValue("x"),
|
||||
placement: { kind: "header", header: "Authorization" },
|
||||
}),
|
||||
).toThrow("not supported");
|
||||
|
||||
expect(() =>
|
||||
store.createCredential({
|
||||
serviceId: service.id,
|
||||
name: "bad2",
|
||||
kind: "api_key",
|
||||
value: encodeCredentialValue("x"),
|
||||
placement: { kind: "api_key", header: "X", queryParam: "token" },
|
||||
}),
|
||||
).toThrow("Invalid credential placement");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Credential } from "../cli-press-types.js";
|
||||
import { applyCredentialToRequest, decodeCredentialValue, encodeCredentialValue } from "../credentials.js";
|
||||
|
||||
function baseCredential(partial: Partial<Credential>): Credential {
|
||||
return {
|
||||
id: "cred_1",
|
||||
serviceId: "svc_1",
|
||||
name: "cred",
|
||||
kind: "header",
|
||||
value: encodeCredentialValue("secret"),
|
||||
placement: { kind: "header", header: "X-Custom-Token" },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...partial,
|
||||
} as Credential;
|
||||
}
|
||||
|
||||
describe("credentials", () => {
|
||||
it("encodes and decodes", () => {
|
||||
const encoded = encodeCredentialValue("alice:s3cret");
|
||||
expect(encoded.encoding).toBe("base64");
|
||||
expect(decodeCredentialValue(encoded)).toBe("alice:s3cret");
|
||||
});
|
||||
|
||||
it("applies all credential kinds", () => {
|
||||
const request = { headers: {}, query: {}, env: {} };
|
||||
|
||||
applyCredentialToRequest(baseCredential({ kind: "header", placement: { kind: "header", header: "X-Custom-Token" } }), request);
|
||||
applyCredentialToRequest(baseCredential({ kind: "query_param", placement: { kind: "query_param", queryParam: "api_token" } }), request);
|
||||
applyCredentialToRequest(baseCredential({ kind: "env_var", placement: { kind: "env_var", envVar: "GITHUB_TOKEN" } }), request);
|
||||
applyCredentialToRequest(baseCredential({ kind: "bearer_token", placement: { kind: "bearer_token", header: "Authorization" } }), request);
|
||||
applyCredentialToRequest(baseCredential({ kind: "api_key", placement: { kind: "api_key", header: "X-API-Key" } }), request);
|
||||
applyCredentialToRequest(baseCredential({ kind: "api_key", placement: { kind: "api_key", queryParam: "api_key" } }), request);
|
||||
applyCredentialToRequest(
|
||||
baseCredential({
|
||||
kind: "basic_auth",
|
||||
value: encodeCredentialValue("alice:s3cret"),
|
||||
placement: { kind: "basic_auth", header: "Authorization" },
|
||||
}),
|
||||
request,
|
||||
);
|
||||
|
||||
expect(request.headers["X-Custom-Token"]).toBe("secret");
|
||||
expect(request.query.api_token).toBe("secret");
|
||||
expect(request.env.GITHUB_TOKEN).toBe("secret");
|
||||
expect(request.headers.Authorization).toBe("Basic YWxpY2U6czNjcmV0");
|
||||
expect(request.headers["X-API-Key"]).toBe("secret");
|
||||
expect(request.query.api_key).toBe("secret");
|
||||
});
|
||||
|
||||
it("throws on placement mismatch and oauth", () => {
|
||||
const request = { headers: {}, query: {}, env: {} };
|
||||
expect(() =>
|
||||
applyCredentialToRequest(baseCredential({ kind: "header", placement: { kind: "query_param", queryParam: "x" } as any }), request),
|
||||
).toThrow("Invalid credential placement");
|
||||
|
||||
expect(() =>
|
||||
applyCredentialToRequest(baseCredential({ kind: "oauth" as any, placement: { kind: "oauth", header: "Authorization" } as any }), request),
|
||||
).toThrow("not supported");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Database } from "@fusion/core";
|
||||
import {
|
||||
InvalidCredentialPlacementError,
|
||||
OAuthNotSupportedError,
|
||||
type CliArtifact,
|
||||
type CliArtifactCreateInput,
|
||||
type CliArtifactUpdateInput,
|
||||
type CliSpec,
|
||||
type CliSpecCreateInput,
|
||||
type CliSpecUpdateInput,
|
||||
type Credential,
|
||||
type CredentialCreateInput,
|
||||
type CredentialUpdateInput,
|
||||
type Service,
|
||||
type ServiceCreateInput,
|
||||
type ServiceSetting,
|
||||
type ServiceSettingCreateInput,
|
||||
type ServiceUpdateInput,
|
||||
} from "./cli-press-types.js";
|
||||
|
||||
interface ServiceRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
baseUrl: string;
|
||||
sourceKind: Service["sourceKind"];
|
||||
sourceRef: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CliSpecRow {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
generatorVersion: string;
|
||||
specJson: string;
|
||||
generatedAt: string | null;
|
||||
status: CliSpec["status"];
|
||||
lastGenerationError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CliArtifactRow {
|
||||
id: string;
|
||||
cliSpecId: string;
|
||||
kind: CliArtifact["kind"];
|
||||
path: string;
|
||||
executable: number;
|
||||
checksum: string | null;
|
||||
sizeBytes: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CredentialRow {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
kind: Credential["kind"];
|
||||
value: string;
|
||||
placement: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ServiceSettingRow {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
scope: ServiceSetting["scope"];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const OAUTH_KINDS = new Set(["oauth", "oauth2"]);
|
||||
|
||||
function parseJson<T>(value: string): T {
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createId(prefix: "svc" | "cli" | "art" | "cred" | "set"): string {
|
||||
return `${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
function assertCredentialSupported(kind: string): void {
|
||||
if (OAUTH_KINDS.has(kind)) {
|
||||
throw new OAuthNotSupportedError(kind);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlacementConsistency(
|
||||
kind: Credential["kind"] | string,
|
||||
placement: Credential["placement"] | { kind?: string; header?: string; queryParam?: string },
|
||||
): void {
|
||||
const placementKind = (placement as { kind?: string }).kind;
|
||||
if (placementKind !== kind) {
|
||||
throw new InvalidCredentialPlacementError({ credentialKind: kind, placementKind: String(placementKind) });
|
||||
}
|
||||
if (kind === "api_key") {
|
||||
const candidate = placement as { kind?: string; header?: string; queryParam?: string };
|
||||
const hasHeader = typeof candidate.header === "string" && candidate.header.trim().length > 0;
|
||||
const hasQuery = typeof candidate.queryParam === "string" && candidate.queryParam.trim().length > 0;
|
||||
if ((hasHeader ? 1 : 0) + (hasQuery ? 1 : 0) !== 1) {
|
||||
throw new InvalidCredentialPlacementError({ credentialKind: kind, placementKind: String(placementKind) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureCliPressSchema(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cli_press_services (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
displayName TEXT NOT NULL,
|
||||
description TEXT,
|
||||
baseUrl TEXT NOT NULL,
|
||||
sourceKind TEXT NOT NULL,
|
||||
sourceRef TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cli_press_cli_specs (
|
||||
id TEXT PRIMARY KEY,
|
||||
serviceId TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
generatorVersion TEXT NOT NULL,
|
||||
specJson TEXT NOT NULL,
|
||||
generatedAt TEXT,
|
||||
status TEXT NOT NULL,
|
||||
lastGenerationError TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (serviceId) REFERENCES cli_press_services(id) ON DELETE CASCADE,
|
||||
UNIQUE(serviceId, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cli_press_artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
cliSpecId TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
executable INTEGER NOT NULL,
|
||||
checksum TEXT,
|
||||
sizeBytes INTEGER,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (cliSpecId) REFERENCES cli_press_cli_specs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cli_press_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
serviceId TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
placement TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (serviceId) REFERENCES cli_press_services(id) ON DELETE CASCADE,
|
||||
UNIQUE(serviceId, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cli_press_service_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
serviceId TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (serviceId) REFERENCES cli_press_services(id) ON DELETE CASCADE,
|
||||
UNIQUE(serviceId, key, scope)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_press_specs_service ON cli_press_cli_specs(serviceId);
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_press_artifacts_spec ON cli_press_artifacts(cliSpecId);
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_press_credentials_service ON cli_press_credentials(serviceId);
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_press_settings_service ON cli_press_service_settings(serviceId);
|
||||
`);
|
||||
}
|
||||
|
||||
export function createCliPressStore(db: Database) {
|
||||
ensureCliPressSchema(db);
|
||||
|
||||
const mapService = (row: ServiceRow): Service => ({
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
displayName: row.displayName,
|
||||
description: row.description ?? undefined,
|
||||
baseUrl: row.baseUrl,
|
||||
sourceKind: row.sourceKind,
|
||||
sourceRef: row.sourceRef ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
|
||||
const mapSpec = (row: CliSpecRow): CliSpec => ({
|
||||
id: row.id,
|
||||
serviceId: row.serviceId,
|
||||
name: row.name,
|
||||
version: row.version,
|
||||
generatorVersion: row.generatorVersion,
|
||||
specJson: row.specJson,
|
||||
generatedAt: row.generatedAt ?? undefined,
|
||||
status: row.status,
|
||||
lastGenerationError: row.lastGenerationError ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
|
||||
const mapArtifact = (row: CliArtifactRow): CliArtifact => ({
|
||||
id: row.id,
|
||||
cliSpecId: row.cliSpecId,
|
||||
kind: row.kind,
|
||||
path: row.path,
|
||||
executable: Boolean(row.executable),
|
||||
checksum: row.checksum ?? undefined,
|
||||
sizeBytes: row.sizeBytes ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
|
||||
const mapCredential = (row: CredentialRow): Credential => ({
|
||||
id: row.id,
|
||||
serviceId: row.serviceId,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
value: parseJson(row.value),
|
||||
placement: parseJson(row.placement),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
|
||||
const mapSetting = (row: ServiceSettingRow): ServiceSetting => ({
|
||||
id: row.id,
|
||||
serviceId: row.serviceId,
|
||||
key: row.key,
|
||||
value: row.value,
|
||||
scope: row.scope,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
listServices(): Service[] {
|
||||
const rows = db.prepare("SELECT * FROM cli_press_services ORDER BY createdAt DESC").all() as unknown as ServiceRow[];
|
||||
return rows.map(mapService);
|
||||
},
|
||||
|
||||
getService(id: string): Service | undefined {
|
||||
const row = db.prepare("SELECT * FROM cli_press_services WHERE id = ?").get(id) as unknown as ServiceRow | undefined;
|
||||
return row ? mapService(row) : undefined;
|
||||
},
|
||||
|
||||
createService(input: ServiceCreateInput): Service {
|
||||
const service: Service = { id: createId("svc"), ...input, createdAt: nowIso(), updatedAt: nowIso() };
|
||||
db.prepare(`INSERT INTO cli_press_services (id, slug, displayName, description, baseUrl, sourceKind, sourceRef, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(service.id, service.slug, service.displayName, service.description ?? null, service.baseUrl, service.sourceKind, service.sourceRef ?? null, service.createdAt, service.updatedAt);
|
||||
db.bumpLastModified();
|
||||
return service;
|
||||
},
|
||||
|
||||
updateService(id: string, updates: ServiceUpdateInput): Service {
|
||||
const existing = this.getService(id);
|
||||
if (!existing) throw new Error(`Service ${id} not found`);
|
||||
const updated: Service = { ...existing, ...updates, id: existing.id, slug: existing.slug, createdAt: existing.createdAt, updatedAt: nowIso() };
|
||||
db.prepare(`UPDATE cli_press_services SET displayName = ?, description = ?, baseUrl = ?, sourceKind = ?, sourceRef = ?, updatedAt = ? WHERE id = ?`)
|
||||
.run(updated.displayName, updated.description ?? null, updated.baseUrl, updated.sourceKind, updated.sourceRef ?? null, updated.updatedAt, id);
|
||||
db.bumpLastModified();
|
||||
return updated;
|
||||
},
|
||||
|
||||
deleteService(id: string): void {
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM cli_press_services WHERE id = ?").run(id);
|
||||
});
|
||||
db.bumpLastModified();
|
||||
},
|
||||
|
||||
listSpecs(serviceId: string): CliSpec[] {
|
||||
const rows = db.prepare("SELECT * FROM cli_press_cli_specs WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as CliSpecRow[];
|
||||
return rows.map(mapSpec);
|
||||
},
|
||||
|
||||
getSpec(id: string): CliSpec | undefined {
|
||||
const row = db.prepare("SELECT * FROM cli_press_cli_specs WHERE id = ?").get(id) as unknown as CliSpecRow | undefined;
|
||||
return row ? mapSpec(row) : undefined;
|
||||
},
|
||||
|
||||
createSpec(input: CliSpecCreateInput): CliSpec {
|
||||
const spec: CliSpec = { id: createId("cli"), ...input, createdAt: nowIso(), updatedAt: nowIso() };
|
||||
db.prepare(`INSERT INTO cli_press_cli_specs (id, serviceId, name, version, generatorVersion, specJson, generatedAt, status, lastGenerationError, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(spec.id, spec.serviceId, spec.name, spec.version, spec.generatorVersion, spec.specJson, spec.generatedAt ?? null, spec.status, spec.lastGenerationError ?? null, spec.createdAt, spec.updatedAt);
|
||||
db.bumpLastModified();
|
||||
return spec;
|
||||
},
|
||||
|
||||
updateSpec(id: string, updates: CliSpecUpdateInput): CliSpec {
|
||||
const existing = this.getSpec(id);
|
||||
if (!existing) throw new Error(`Spec ${id} not found`);
|
||||
const updated: CliSpec = { ...existing, ...updates, id: existing.id, serviceId: existing.serviceId, createdAt: existing.createdAt, updatedAt: nowIso() };
|
||||
db.prepare(`UPDATE cli_press_cli_specs SET name=?, version=?, generatorVersion=?, specJson=?, generatedAt=?, status=?, lastGenerationError=?, updatedAt=? WHERE id=?`)
|
||||
.run(updated.name, updated.version, updated.generatorVersion, updated.specJson, updated.generatedAt ?? null, updated.status, updated.lastGenerationError ?? null, updated.updatedAt, id);
|
||||
db.bumpLastModified();
|
||||
return updated;
|
||||
},
|
||||
|
||||
deleteSpec(id: string): void {
|
||||
db.prepare("DELETE FROM cli_press_cli_specs WHERE id = ?").run(id);
|
||||
db.bumpLastModified();
|
||||
},
|
||||
|
||||
listArtifacts(specId: string): CliArtifact[] {
|
||||
const rows = db.prepare("SELECT * FROM cli_press_artifacts WHERE cliSpecId = ? ORDER BY createdAt DESC").all(specId) as unknown as CliArtifactRow[];
|
||||
return rows.map(mapArtifact);
|
||||
},
|
||||
|
||||
createArtifact(input: CliArtifactCreateInput): CliArtifact {
|
||||
const artifact: CliArtifact = { id: createId("art"), ...input, createdAt: nowIso(), updatedAt: nowIso() };
|
||||
db.prepare(`INSERT INTO cli_press_artifacts (id, cliSpecId, kind, path, executable, checksum, sizeBytes, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(artifact.id, artifact.cliSpecId, artifact.kind, artifact.path, artifact.executable ? 1 : 0, artifact.checksum ?? null, artifact.sizeBytes ?? null, artifact.createdAt, artifact.updatedAt);
|
||||
db.bumpLastModified();
|
||||
return artifact;
|
||||
},
|
||||
|
||||
updateArtifact(id: string, updates: CliArtifactUpdateInput): CliArtifact {
|
||||
const existing = db.prepare("SELECT * FROM cli_press_artifacts WHERE id = ?").get(id) as unknown as CliArtifactRow | undefined;
|
||||
if (!existing) throw new Error(`Artifact ${id} not found`);
|
||||
const updated = { ...mapArtifact(existing), ...updates, id: existing.id, cliSpecId: existing.cliSpecId, createdAt: existing.createdAt, updatedAt: nowIso() };
|
||||
db.prepare("UPDATE cli_press_artifacts SET path=?, executable=?, checksum=?, sizeBytes=?, updatedAt=? WHERE id=?")
|
||||
.run(updated.path, updated.executable ? 1 : 0, updated.checksum ?? null, updated.sizeBytes ?? null, updated.updatedAt, id);
|
||||
db.bumpLastModified();
|
||||
return updated;
|
||||
},
|
||||
|
||||
deleteArtifact(id: string): void {
|
||||
db.prepare("DELETE FROM cli_press_artifacts WHERE id = ?").run(id);
|
||||
db.bumpLastModified();
|
||||
},
|
||||
|
||||
listCredentials(serviceId: string): Credential[] {
|
||||
const rows = db.prepare("SELECT * FROM cli_press_credentials WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as CredentialRow[];
|
||||
return rows.map(mapCredential);
|
||||
},
|
||||
|
||||
createCredential(input: CredentialCreateInput): Credential {
|
||||
assertCredentialSupported(input.kind);
|
||||
assertPlacementConsistency(input.kind, input.placement);
|
||||
const cred: Credential = { id: createId("cred"), ...input, createdAt: nowIso(), updatedAt: nowIso() };
|
||||
db.prepare(`INSERT INTO cli_press_credentials (id, serviceId, name, kind, value, placement, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(cred.id, cred.serviceId, cred.name, cred.kind, JSON.stringify(cred.value), JSON.stringify(cred.placement), cred.createdAt, cred.updatedAt);
|
||||
db.bumpLastModified();
|
||||
return cred;
|
||||
},
|
||||
|
||||
updateCredential(id: string, updates: CredentialUpdateInput): Credential {
|
||||
const existing = db.prepare("SELECT * FROM cli_press_credentials WHERE id = ?").get(id) as unknown as CredentialRow | undefined;
|
||||
if (!existing) throw new Error(`Credential ${id} not found`);
|
||||
const mapped = mapCredential(existing);
|
||||
const updated: Credential = {
|
||||
...mapped,
|
||||
...updates,
|
||||
id: mapped.id,
|
||||
serviceId: mapped.serviceId,
|
||||
kind: mapped.kind,
|
||||
createdAt: mapped.createdAt,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
assertCredentialSupported(updated.kind);
|
||||
assertPlacementConsistency(updated.kind, updated.placement);
|
||||
db.prepare("UPDATE cli_press_credentials SET name=?, value=?, placement=?, updatedAt=? WHERE id=?")
|
||||
.run(updated.name, JSON.stringify(updated.value), JSON.stringify(updated.placement), updated.updatedAt, id);
|
||||
db.bumpLastModified();
|
||||
return updated;
|
||||
},
|
||||
|
||||
deleteCredential(id: string): void {
|
||||
db.prepare("DELETE FROM cli_press_credentials WHERE id = ?").run(id);
|
||||
db.bumpLastModified();
|
||||
},
|
||||
|
||||
listSettings(serviceId: string): ServiceSetting[] {
|
||||
const rows = db.prepare("SELECT * FROM cli_press_service_settings WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as ServiceSettingRow[];
|
||||
return rows.map(mapSetting);
|
||||
},
|
||||
|
||||
setSetting(input: ServiceSettingCreateInput): ServiceSetting {
|
||||
const existing = db.prepare("SELECT * FROM cli_press_service_settings WHERE serviceId = ? AND key = ? AND scope = ?")
|
||||
.get(input.serviceId, input.key, input.scope) as unknown as ServiceSettingRow | undefined;
|
||||
const now = nowIso();
|
||||
if (existing) {
|
||||
db.prepare("UPDATE cli_press_service_settings SET value = ?, updatedAt = ? WHERE id = ?").run(input.value, now, existing.id);
|
||||
db.bumpLastModified();
|
||||
return mapSetting({ ...existing, value: input.value, updatedAt: now });
|
||||
}
|
||||
const setting: ServiceSetting = { id: createId("set"), ...input, createdAt: now, updatedAt: now };
|
||||
db.prepare(`INSERT INTO cli_press_service_settings (id, serviceId, key, value, scope, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(setting.id, setting.serviceId, setting.key, setting.value, setting.scope, setting.createdAt, setting.updatedAt);
|
||||
db.bumpLastModified();
|
||||
return setting;
|
||||
},
|
||||
|
||||
deleteSetting(id: string): void {
|
||||
db.prepare("DELETE FROM cli_press_service_settings WHERE id = ?").run(id);
|
||||
db.bumpLastModified();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CliPressStore = ReturnType<typeof createCliPressStore>;
|
||||
@@ -0,0 +1,110 @@
|
||||
export type ServiceSourceKind = "openapi" | "manual" | "other";
|
||||
|
||||
export type CredentialKind = "api_key" | "bearer_token" | "basic_auth" | "header" | "query_param" | "env_var";
|
||||
|
||||
export type CredentialPlacement =
|
||||
| { kind: "header"; header: string }
|
||||
| { kind: "query_param"; queryParam: string }
|
||||
| { kind: "env_var"; envVar: string }
|
||||
| { kind: "bearer_token"; header: string }
|
||||
| { kind: "api_key"; header?: string; queryParam?: string }
|
||||
| { kind: "basic_auth"; header: string };
|
||||
|
||||
export type ServiceSettingScope = "runtime" | "wizard" | "metadata";
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
baseUrl: string;
|
||||
sourceKind: ServiceSourceKind;
|
||||
sourceRef?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CliSpec {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
generatorVersion: string;
|
||||
specJson: string;
|
||||
generatedAt?: string;
|
||||
status: "draft" | "generated" | "failed";
|
||||
lastGenerationError?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CliArtifact {
|
||||
id: string;
|
||||
cliSpecId: string;
|
||||
kind: "binary" | "script" | "package";
|
||||
path: string;
|
||||
executable: boolean;
|
||||
checksum?: string;
|
||||
sizeBytes?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EncodedCredentialValue {
|
||||
encoding: "base64";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Credential {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
kind: CredentialKind;
|
||||
value: EncodedCredentialValue;
|
||||
placement: CredentialPlacement;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceSetting {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
scope: ServiceSettingScope;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export class OAuthNotSupportedError extends Error {
|
||||
constructor(kind: string) {
|
||||
super(`Credential kind \"${kind}\" is not supported. OAuth/OAuth2 are deferred.`);
|
||||
this.name = "OAuthNotSupportedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCredentialPlacementError extends Error {
|
||||
constructor(input: { credentialKind: string; placementKind: string }) {
|
||||
super(
|
||||
`Invalid credential placement: credential kind \"${input.credentialKind}\" does not match placement kind \"${input.placementKind}\".`,
|
||||
);
|
||||
this.name = "InvalidCredentialPlacementError";
|
||||
}
|
||||
}
|
||||
|
||||
export type ServiceCreateInput = Omit<Service, "id" | "createdAt" | "updatedAt">;
|
||||
export type ServiceUpdateInput = Partial<Pick<Service, "displayName" | "description" | "baseUrl" | "sourceKind" | "sourceRef">>;
|
||||
|
||||
export type CliSpecCreateInput = Omit<CliSpec, "id" | "createdAt" | "updatedAt">;
|
||||
export type CliSpecUpdateInput = Partial<
|
||||
Pick<CliSpec, "name" | "version" | "generatorVersion" | "specJson" | "status" | "lastGenerationError" | "generatedAt">
|
||||
>;
|
||||
|
||||
export type CliArtifactCreateInput = Omit<CliArtifact, "id" | "createdAt" | "updatedAt">;
|
||||
export type CliArtifactUpdateInput = Partial<Pick<CliArtifact, "path" | "executable" | "checksum" | "sizeBytes">>;
|
||||
|
||||
export type CredentialCreateInput = Omit<Credential, "id" | "createdAt" | "updatedAt">;
|
||||
export type CredentialUpdateInput = Partial<Pick<Credential, "name" | "value" | "placement">>;
|
||||
|
||||
export type ServiceSettingCreateInput = Omit<ServiceSetting, "id" | "createdAt" | "updatedAt">;
|
||||
export type ServiceSettingUpdateInput = Pick<ServiceSetting, "value">;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Credential, EncodedCredentialValue } from "./cli-press-types.js";
|
||||
import { InvalidCredentialPlacementError, OAuthNotSupportedError } from "./cli-press-types.js";
|
||||
|
||||
export type RequestShape = {
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string>;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
export function encodeCredentialValue(raw: string): EncodedCredentialValue {
|
||||
return {
|
||||
encoding: "base64",
|
||||
value: Buffer.from(raw, "utf8").toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeCredentialValue(encoded: EncodedCredentialValue): string {
|
||||
if (encoded.encoding !== "base64") {
|
||||
throw new Error(`Unsupported credential encoding: ${String((encoded as { encoding?: string }).encoding)}`);
|
||||
}
|
||||
return Buffer.from(encoded.value, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
function assertNoOAuth(kind: string): void {
|
||||
if (kind === "oauth" || kind === "oauth2") {
|
||||
throw new OAuthNotSupportedError(kind);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlacement(credential: Credential): void {
|
||||
const credentialKind = credential.kind;
|
||||
const placementKind = credential.placement.kind;
|
||||
if (placementKind !== credentialKind) {
|
||||
throw new InvalidCredentialPlacementError({ credentialKind, placementKind });
|
||||
}
|
||||
if (credential.kind === "api_key") {
|
||||
const placement = credential.placement;
|
||||
if (placement.kind !== "api_key") {
|
||||
throw new InvalidCredentialPlacementError({ credentialKind, placementKind });
|
||||
}
|
||||
const hasHeader = typeof placement.header === "string" && placement.header.trim().length > 0;
|
||||
const hasQuery = typeof placement.queryParam === "string" && placement.queryParam.trim().length > 0;
|
||||
if ((hasHeader ? 1 : 0) + (hasQuery ? 1 : 0) !== 1) {
|
||||
throw new InvalidCredentialPlacementError({ credentialKind, placementKind });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyCredentialToRequest(credential: Credential, request: RequestShape): RequestShape {
|
||||
assertNoOAuth((credential as { kind: string }).kind);
|
||||
assertPlacement(credential);
|
||||
|
||||
const value = decodeCredentialValue(credential.value);
|
||||
|
||||
switch (credential.kind) {
|
||||
case "header": {
|
||||
const placement = credential.placement as { kind: "header"; header: string };
|
||||
request.headers[placement.header] = value;
|
||||
break;
|
||||
}
|
||||
case "query_param": {
|
||||
const placement = credential.placement as { kind: "query_param"; queryParam: string };
|
||||
request.query[placement.queryParam] = value;
|
||||
break;
|
||||
}
|
||||
case "env_var": {
|
||||
const placement = credential.placement as { kind: "env_var"; envVar: string };
|
||||
request.env[placement.envVar] = value;
|
||||
break;
|
||||
}
|
||||
case "bearer_token": {
|
||||
const placement = credential.placement as { kind: "bearer_token"; header: string };
|
||||
request.headers[placement.header] = `Bearer ${value}`;
|
||||
break;
|
||||
}
|
||||
case "api_key": {
|
||||
const placement = credential.placement as { kind: "api_key"; header?: string; queryParam?: string };
|
||||
if (placement.header) {
|
||||
request.headers[placement.header] = value;
|
||||
} else if (placement.queryParam) {
|
||||
request.query[placement.queryParam] = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "basic_auth": {
|
||||
const placement = credential.placement as { kind: "basic_auth"; header: string };
|
||||
const basicAuth = Buffer.from(value, "utf8").toString("base64");
|
||||
request.headers[placement.header] = `Basic ${basicAuth}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
Reference in New Issue
Block a user