@suluk/cockpit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@suluk/cockpit",
3
+ "version": "0.1.0",
4
+ "description": "The pure cockpit core (cycle model · builder model · codegen · deploy planning · validate/audit/preview) shared by the vscode extension and the /superadmin web admin panel. CANDIDATE tooling.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "@suluk/core": "0.1.0",
12
+ "@suluk/hono": "0.1.0",
13
+ "@suluk/scalar": "0.1.0",
14
+ "@suluk/swagger": "0.1.0",
15
+ "@suluk/shadcn": "0.1.0",
16
+ "@suluk/builder": "0.1.0",
17
+ "@suluk/deploy": "0.1.0",
18
+ "@suluk/cost": "0.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "latest"
22
+ },
23
+ "scripts": {
24
+ "test": "bun test",
25
+ "typecheck": "tsc --noEmit -p ."
26
+ }
27
+ }
package/src/builder.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The cockpit's BUILDER surface (pure logic). Treats the hub document's components.schemas as entities, runs
3
+ * @suluk/builder over them, and exposes the tiered composition (pages → sections → blocks → components) with
4
+ * EACH tier's param contract attached — so the contract-narrowing is visible in the tree (a section node
5
+ * shows it exposes {tone, blocks}; you can read straight off it that a page can't reach the form's fields).
6
+ * Plus the two actions that land artifacts: generate the whole app, and export the shadcn registry.
7
+ */
8
+ import type { OpenAPIv4Document, SchemaOrRef } from "@suluk/core";
9
+ import { buildApp, toShadcnRegistry, type Entity, type BuiltApp, type DslDocument, type ParamSpec } from "@suluk/builder";
10
+ import type { Registry } from "@suluk/builder";
11
+
12
+ export interface BuilderNode {
13
+ tier: "page" | "section" | "block" | "component";
14
+ label: string;
15
+ /** The param-contract keys this tier exposes upward (empty for a leaf component). */
16
+ contract: string[];
17
+ children: BuilderNode[];
18
+ }
19
+
20
+ /** Each components.schemas entry becomes a builder entity. */
21
+ export function entitiesFromDoc(doc: OpenAPIv4Document): Entity[] {
22
+ const schemas = (doc.components?.schemas ?? {}) as Record<string, SchemaOrRef>;
23
+ return Object.entries(schemas).map(([name, schema]) => ({ name, schema }));
24
+ }
25
+
26
+ function contractKeys(doc: DslDocument | undefined): string[] {
27
+ return Object.keys(doc?.params ?? {});
28
+ }
29
+
30
+ function listOptions(doc: DslDocument | undefined, key: string): string[] {
31
+ const spec = doc?.params?.[key] as ParamSpec | undefined;
32
+ return spec && spec.type === "list" ? spec.options : [];
33
+ }
34
+
35
+ function blockNode(reg: Registry, name: string): BuilderNode {
36
+ const block = reg.blocks[name];
37
+ const componentType = block?.root.type ?? "?";
38
+ return {
39
+ tier: "block", label: name, contract: contractKeys(block),
40
+ children: [{ tier: "component", label: componentType, contract: [], children: [] }],
41
+ };
42
+ }
43
+
44
+ function sectionNode(reg: Registry, name: string): BuilderNode {
45
+ const section = reg.sections[name];
46
+ return {
47
+ tier: "section", label: name, contract: contractKeys(section),
48
+ children: listOptions(section, "blocks").map((b) => blockNode(reg, b)),
49
+ };
50
+ }
51
+
52
+ function pageNode(reg: Registry, page: DslDocument): BuilderNode {
53
+ return {
54
+ tier: "page", label: page.name, contract: contractKeys(page),
55
+ children: listOptions(page, "sections").map((s) => sectionNode(reg, s)),
56
+ };
57
+ }
58
+
59
+ /** The full tier tree (pages → sections → blocks → components) with each tier's contract. */
60
+ export function builderTree(app: BuiltApp): BuilderNode[] {
61
+ return Object.values(app.registry.pages).map((p) => pageNode(app.registry, p));
62
+ }
63
+
64
+ export interface BuilderModel {
65
+ app: BuiltApp;
66
+ tree: BuilderNode[];
67
+ /** DSL contract violations (empty ⇒ sound). */
68
+ errors: { doc: string; path: string; message: string }[];
69
+ entityCount: number;
70
+ }
71
+
72
+ /** Build the full builder model from a v4 document (its schemas → entities → buildApp). */
73
+ export function buildBuilderModel(doc: OpenAPIv4Document): BuilderModel {
74
+ const entities = entitiesFromDoc(doc);
75
+ const app = buildApp({ entities, info: { title: doc.info?.title ?? "App", version: doc.info?.version ?? "0.0.0" } });
76
+ return { app, tree: builderTree(app), errors: app.errors, entityCount: entities.length };
77
+ }
78
+
79
+ export interface GeneratedFile { path: string; content: string }
80
+
81
+ /** All files for the generated app: the v4 doc, the frontend components + pages, and the shadcn registry. */
82
+ export function generateAppFiles(doc: OpenAPIv4Document): GeneratedFile[] {
83
+ const { app } = buildBuilderModel(doc);
84
+ const files: GeneratedFile[] = [];
85
+ files.push({ path: "openapi.json", content: JSON.stringify(app.backend.document, null, 2) });
86
+ for (const c of app.frontend.components) files.push({ path: `components/${c.name}.tsx`, content: c.tsx });
87
+ for (const p of app.frontend.pages) files.push({ path: `app/${p.name.toLowerCase()}/page.tsx`, content: p.tsx });
88
+ const reg = toShadcnRegistry(app, { name: (doc.info?.title ?? "app").toLowerCase().replace(/\s+/g, "-") });
89
+ files.push({ path: "registry.json", content: JSON.stringify(reg.index, null, 2) });
90
+ for (const item of reg.items) files.push({ path: `registry/${item.name}.json`, content: JSON.stringify(item, null, 2) });
91
+ return files;
92
+ }
93
+
94
+ /** The shadcn registry (index + items) as a pretty JSON string — the "Export shadcn registry" action. */
95
+ export function generateRegistryJson(doc: OpenAPIv4Document): string {
96
+ const { app } = buildBuilderModel(doc);
97
+ const reg = toShadcnRegistry(app, { name: (doc.info?.title ?? "app").toLowerCase().replace(/\s+/g, "-") });
98
+ return JSON.stringify({ registry: reg.index, items: reg.items }, null, 2);
99
+ }
package/src/codegen.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The cockpit's action side — codegen that LANDS files. Every generator is a pure function over the v4 hub
3
+ * document, reusing the projection packages (so the editor never reimplements a projection). The vscode shell
4
+ * just writes the returned string to a file the user picks.
5
+ */
6
+ import { parseDocument } from "@suluk/core";
7
+ import type { OpenAPIv4Document, SchemaOrRef } from "@suluk/core";
8
+ import { formSpec, tableSpec, renderFormTsx, renderTableTsx } from "@suluk/shadcn";
9
+ import { buildCycle } from "./cycle";
10
+
11
+ /** Entity names available for codegen (components.schemas). */
12
+ export function entityNames(doc: OpenAPIv4Document): string[] {
13
+ return Object.keys(doc.components?.schemas ?? {});
14
+ }
15
+
16
+ function entitySchema(doc: OpenAPIv4Document, name: string): { schema: SchemaOrRef; defs: Record<string, SchemaOrRef> } {
17
+ const schemas = (doc.components?.schemas ?? {}) as Record<string, SchemaOrRef>;
18
+ const schema = schemas[name];
19
+ if (!schema) throw new Error(`no entity '${name}' in components.schemas`);
20
+ return { schema, defs: schemas };
21
+ }
22
+
23
+ /** Generate a shadcn form component (TSX) for an entity. */
24
+ export function generateForm(doc: OpenAPIv4Document, name: string): string {
25
+ const { schema, defs } = entitySchema(doc, name);
26
+ return renderFormTsx(formSpec(schema, { defs }), { componentName: `${name}Form`, schemaName: `${name}Schema` });
27
+ }
28
+
29
+ /** Generate a shadcn table component (TSX) for an entity. */
30
+ export function generateTable(doc: OpenAPIv4Document, name: string): string {
31
+ const { schema, defs } = entitySchema(doc, name);
32
+ return renderTableTsx(tableSpec(schema, { defs }), { componentName: `${name}Table` });
33
+ }
34
+
35
+ /**
36
+ * Generate the Nano Stores client wiring. @suluk/nano-stores is a runtime helper (createApiStores(routes)),
37
+ * so the "codegen" is a thin, honest scaffold: it wires the user's RouteContracts to a typed store client and
38
+ * lists, in comments, the exact stores the cockpit derived from the current document.
39
+ */
40
+ export function generateStoresModule(doc: OpenAPIv4Document, opts: { baseUrl?: string } = {}): string {
41
+ const model = buildCycle(doc);
42
+ const state = model.layers.find((l) => l.id === "state");
43
+ const lines = (state?.items ?? []).map((i) => ` * ${i.label.padEnd(24)} ${i.detail}`).join("\n");
44
+ const baseUrl = opts.baseUrl ?? "/api";
45
+ return `// CANDIDATE — generated by suluk-vscode. Not official OAS tooling.
46
+ //
47
+ // Nano Stores client for this API. @suluk/nano-stores projects your RouteContracts (the same ones your
48
+ // Hono server is derived from) into typed fetcher/mutator stores. Wire your contracts here:
49
+ //
50
+ // Stores derived from the current v4 document:
51
+ ${lines || " * (no operations)"}
52
+ //
53
+ import { createApiStores } from "@suluk/nano-stores";
54
+ import { routes } from "./routes"; // your @suluk/hono RouteContract[] (the single source)
55
+
56
+ export const api = createApiStores(routes, { baseUrl: ${JSON.stringify(baseUrl)} });
57
+
58
+ // Usage:
59
+ // const $pet = api.fetchers.getPet({ petId: "123" }); // GET → fetcher store
60
+ // await api.mutators.createPet.mutate({ data: { name: "Rex", tags: [] } }); // non-GET → mutator
61
+ `;
62
+ }
63
+
64
+ /** Export the v4 document as pretty JSON (the canonical interchange artifact). */
65
+ export function exportV4Json(source: string): string {
66
+ return JSON.stringify(parseDocument(source), null, 2);
67
+ }
package/src/cycle.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The cycle model — the cockpit's spine. From the ONE hub artifact (a v4 "Suluk" document) it computes a
3
+ * layered view of the whole declarative cycle: data entities, the API contract, auth, the document's health,
4
+ * the docs, the derived client stores, the derived UI, and the contract checks. Every layer is a *projection*
5
+ * of the same source, so the cockpit can show the lineage at a glance.
6
+ *
7
+ * It is also a FUNCTION of the requesting principal (the "who"): pass scopes and scope-gated operations the
8
+ * principal can't reach are filtered out — the same per-viewer projection emitV4 does, applied at the hub.
9
+ *
10
+ * Pure (no vscode) → unit-tested with bun. The extension shell renders this into a TreeView.
11
+ */
12
+ import { buildAda, validateDocument, isReference } from "@suluk/core";
13
+ import type { OpenAPIv4Document, Request, SchemaOrRef, SecurityRequirement } from "@suluk/core";
14
+ import { audit, coverage } from "@suluk/hono";
15
+ import { formSpec, tableSpec } from "@suluk/shadcn";
16
+ import { costAudit, costTable, formatMicroUsd } from "@suluk/cost";
17
+
18
+ export type LayerStatus = "ok" | "warn" | "error" | "info";
19
+
20
+ export interface CycleItem {
21
+ label: string;
22
+ detail?: string;
23
+ status?: LayerStatus;
24
+ /** A stable handle (e.g. an entity or operation name) for command targeting. */
25
+ ref?: string;
26
+ }
27
+
28
+ export interface CycleLayer {
29
+ id: "data" | "contract" | "auth" | "document" | "cost" | "docs" | "state" | "ui" | "tests";
30
+ title: string;
31
+ status: LayerStatus;
32
+ summary: string;
33
+ items: CycleItem[];
34
+ }
35
+
36
+ export interface Principal {
37
+ scopes?: string[];
38
+ }
39
+
40
+ export interface CycleModel {
41
+ valid: boolean;
42
+ coverage: number;
43
+ /** The principal this view was projected for (undefined ⇒ the full/public view). */
44
+ principal?: Principal;
45
+ layers: CycleLayer[];
46
+ }
47
+
48
+ /** Scopes an operation requires (union across its security requirements; empty ⇒ public). */
49
+ function requiredScopes(req: Request): string[][] {
50
+ const reqs = (req.security as SecurityRequirement[] | undefined) ?? [];
51
+ // each requirement is an AND of schemes; we collapse a requirement to the union of its scopes.
52
+ return reqs.map((r) => Object.values(r).flat());
53
+ }
54
+
55
+ /** Is `req` visible to a principal holding `scopes`? Public ops always; else ANY requirement must be satisfied. */
56
+ function visibleTo(req: Request, scopes: Set<string> | undefined): boolean {
57
+ if (!scopes) return true; // no principal ⇒ full view
58
+ const reqs = requiredScopes(req);
59
+ if (reqs.length === 0) return true; // public op
60
+ return reqs.some((needed) => needed.every((s) => scopes.has(s)));
61
+ }
62
+
63
+ function schemaFieldCount(schema: SchemaOrRef, defs: Record<string, SchemaOrRef>): number {
64
+ try {
65
+ return formSpec(schema, { defs }).fields.length;
66
+ } catch {
67
+ return 0;
68
+ }
69
+ }
70
+
71
+ function pickStatus(...statuses: LayerStatus[]): LayerStatus {
72
+ if (statuses.includes("error")) return "error";
73
+ if (statuses.includes("warn")) return "warn";
74
+ if (statuses.includes("info")) return "info";
75
+ return "ok";
76
+ }
77
+
78
+ /** Build the full cycle model from a v4 document, optionally projected for a principal (the "who"). */
79
+ export function buildCycle(doc: OpenAPIv4Document, opts: { principal?: Principal } = {}): CycleModel {
80
+ const schemas = (doc.components?.schemas ?? {}) as Record<string, SchemaOrRef>;
81
+ const defs = schemas;
82
+ const securitySchemes = doc.components?.securitySchemes ?? {};
83
+ const scopes = opts.principal ? new Set(opts.principal.scopes ?? []) : undefined;
84
+
85
+ const ada = buildAda(doc);
86
+ // operations, filtered by the principal (the per-viewer projection)
87
+ const ops = ada.operations.filter((o) => visibleTo(o.request, scopes));
88
+ const hiddenCount = ada.operations.length - ops.length;
89
+
90
+ // ── data: components.schemas (entities). x-suluk-db marks a DB-backed table (e.g. from @suluk/drizzle).
91
+ const dataItems: CycleItem[] = Object.entries(schemas).map(([name, s]) => {
92
+ const isTable = !isReference(s) && typeof s === "object" && s !== null && "x-suluk-db" in (s as object);
93
+ return { label: name, ref: name, detail: `${isTable ? "table" : "schema"} · ${schemaFieldCount(s, defs)} fields` };
94
+ });
95
+
96
+ // ── contract: the operations (API surface)
97
+ const scopeGated = ops.filter((o) => requiredScopes(o.request).some((r) => r.length > 0)).length;
98
+ const contractItems: CycleItem[] = ops.map((o) => ({
99
+ label: o.name,
100
+ ref: o.name,
101
+ detail: `${o.request.method.toUpperCase()} ${o.pathTemplate}`,
102
+ status: requiredScopes(o.request).some((r) => r.length > 0) ? "info" : undefined,
103
+ }));
104
+
105
+ // ── auth
106
+ const authItems: CycleItem[] = Object.entries(securitySchemes).map(([name, s]) => ({
107
+ label: name,
108
+ detail: (s as { type?: string }).type ?? "scheme",
109
+ }));
110
+
111
+ // ── document health
112
+ const v = validateDocument(doc);
113
+ const cov = coverage(doc);
114
+ const findings = audit(doc);
115
+ const warnFindings = findings.filter((f) => f.severity === "warn").length;
116
+
117
+ // ── docs (always derivable via the 3.1 downgrade)
118
+ const docsItems: CycleItem[] = [
119
+ { label: "Scalar", detail: "Preview in Scalar (3.1 downgrade)" },
120
+ { label: "Swagger UI", detail: "Preview in Swagger UI (3.1 downgrade)" },
121
+ ];
122
+
123
+ // ── state: one nano-store per operation (GET → fetcher, else mutator)
124
+ const stateItems: CycleItem[] = ops.map((o) => ({
125
+ label: o.name,
126
+ ref: o.name,
127
+ detail: o.request.method.toUpperCase() === "GET" ? "fetcher store" : "mutator store",
128
+ }));
129
+
130
+ // ── ui: a shadcn form + table per entity
131
+ const uiItems: CycleItem[] = Object.entries(schemas).map(([name, s]) => {
132
+ let cols = 0;
133
+ try { cols = tableSpec(s, { defs }).columns.length; } catch { cols = 0; }
134
+ return { label: name, ref: name, detail: `form (${schemaFieldCount(s, defs)} fields) · table (${cols} cols)` };
135
+ });
136
+
137
+ // ── cost: per-operation declared cost (x-suluk-cost) + coverage (which operations declared nothing)
138
+ const declaredCosts = costTable(doc);
139
+ const costFindings = costAudit(doc);
140
+ const undeclared = costFindings.filter((f) => f.code === "no-cost-model").length;
141
+ const costItems: CycleItem[] = declaredCosts.map((d) => ({ label: d.operation, ref: d.operation, detail: `${formatMicroUsd(d.estimateMicroUsd)} · ${d.sources.join(", ")}` }));
142
+
143
+ // ── tests: doc-level contract checks
144
+ const checks = docChecks(doc);
145
+ const testsItems: CycleItem[] = checks.map((c) => ({ label: c.name, status: c.pass ? "ok" : "error", detail: c.pass ? "pass" : c.message }));
146
+
147
+ const layers: CycleLayer[] = [
148
+ { id: "data", title: "Data (entities)", status: dataItems.length ? "ok" : "info", summary: `${dataItems.length} entities`, items: dataItems },
149
+ {
150
+ id: "contract", title: "Contract (API)",
151
+ status: "ok",
152
+ summary: `${ops.length} operations · ${scopeGated} scope-gated${hiddenCount ? ` · ${hiddenCount} hidden for this viewer` : ""}`,
153
+ items: contractItems,
154
+ },
155
+ { id: "auth", title: "Auth", status: authItems.length ? "ok" : "info", summary: authItems.length ? `${authItems.length} schemes` : "none", items: authItems },
156
+ {
157
+ id: "document", title: "Document (v4)",
158
+ status: pickStatus(v.valid ? "ok" : "error", warnFindings ? "warn" : "ok"),
159
+ summary: `${v.valid ? "meta-schema ✓" : `${v.errors.length} errors`} · coverage ${cov.toFixed(2)} · ${warnFindings} audit warns`,
160
+ items: v.valid ? [] : v.errors.slice(0, 20).map((e) => ({ label: e.message, detail: e.path, status: "error" as const })),
161
+ },
162
+ {
163
+ id: "cost", title: "Cost",
164
+ status: declaredCosts.length === 0 ? "info" : undeclared ? "warn" : "ok",
165
+ summary: declaredCosts.length === 0 ? "no costs declared" : `${declaredCosts.length} priced · ${undeclared} undeclared`,
166
+ items: costItems,
167
+ },
168
+ { id: "docs", title: "Docs", status: "ok", summary: "Scalar · Swagger", items: docsItems },
169
+ { id: "state", title: "State (Nano Stores)", status: "ok", summary: `${stateItems.length} stores`, items: stateItems },
170
+ { id: "ui", title: "UI (shadcn)", status: uiItems.length ? "ok" : "info", summary: `${uiItems.length} forms/tables`, items: uiItems },
171
+ {
172
+ id: "tests", title: "Tests (contract checks)",
173
+ status: checks.every((c) => c.pass) ? "ok" : "error",
174
+ summary: `${checks.filter((c) => c.pass).length}/${checks.length} ✓`,
175
+ items: testsItems,
176
+ },
177
+ ];
178
+
179
+ return { valid: v.valid, coverage: cov, principal: opts.principal, layers };
180
+ }
181
+
182
+ export interface DocCheck { name: string; pass: boolean; message: string }
183
+
184
+ /** Doc-level "contract checks" — the mistakes a finished v4 document can still encode. */
185
+ export function docChecks(doc: OpenAPIv4Document): DocCheck[] {
186
+ const out: DocCheck[] = [];
187
+ const v = validateDocument(doc);
188
+ out.push({ name: "validates against the v4 meta-schema", pass: v.valid, message: v.errors.slice(0, 3).map((e) => `${e.path} ${e.message}`).join("; ") });
189
+ const ada = buildAda(doc);
190
+ const provable = ada.collisions.filter((c) => c.verdict === "provable-collision");
191
+ out.push({ name: "no two operations provably collide", pass: provable.length === 0, message: provable.map((c) => `${c.a.name} vs ${c.b.name}`).join("; ") });
192
+ const ops = ada.operations.length;
193
+ out.push({ name: "every path has at least one operation", pass: ops > 0 || Object.keys(doc.paths ?? {}).length === 0, message: "a pathItem has no requests" });
194
+ return out;
195
+ }
196
+
197
+ /** A flat list for simple renderers / status lines. */
198
+ export function cycleSummary(model: CycleModel): { layer: string; summary: string; status: LayerStatus }[] {
199
+ return model.layers.map((l) => ({ layer: l.title, summary: l.summary, status: l.status }));
200
+ }
package/src/deploy.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The cockpit's DEPLOY surface (pure logic). Turns the hub document into a Cloudflare deploy plan via
3
+ * @suluk/deploy (the swappable provider), and renders a DEPLOY.md the user follows. The extension writes the
4
+ * files + opens a terminal; it never runs wrangler for you — deploys are consequential, and auth (`wrangler
5
+ * login`, OAuth) happens in your terminal so credentials never touch Suluk.
6
+ */
7
+ import type { OpenAPIv4Document } from "@suluk/core";
8
+ import { cloudflare, type DeployPlan } from "@suluk/deploy";
9
+ import { entitiesFromDoc } from "./builder";
10
+
11
+ /** Build the Cloudflare deploy plan from a v4 document (its schemas → entities). */
12
+ export function deployPlan(doc: OpenAPIv4Document): DeployPlan {
13
+ return cloudflare.generate({
14
+ name: doc.info?.title ?? "suluk-app",
15
+ entities: entitiesFromDoc(doc),
16
+ appModule: "./src/app",
17
+ assetsDir: "./dist/client",
18
+ });
19
+ }
20
+
21
+ /** Render the deploy plan as a DEPLOY.md the user can follow step by step. */
22
+ export function deployMarkdown(plan: DeployPlan): string {
23
+ const steps = plan.steps.map((s, i) => `${i + 1}. \`${s.cmd}\`\n - ${s.note}`).join("\n");
24
+ const notes = plan.notes.map((n) => `- ${n}`).join("\n");
25
+ const files = plan.files.map((f) => `- \`${f.path}\``).join("\n");
26
+ return `# Deploy to Cloudflare — CANDIDATE (generated by Suluk)
27
+
28
+ Your stack is Cloudflare-native: the Hono app is the Worker, the data floor (sqlite-core) is D1, and the
29
+ built frontend is served as static assets. These files were generated for you:
30
+
31
+ ${files}
32
+
33
+ ## Steps (run them in the integrated terminal)
34
+
35
+ ${steps}
36
+
37
+ ## Notes
38
+
39
+ ${notes}
40
+
41
+ > Suluk never runs these for you — \`wrangler login\` opens an OAuth flow in your browser, and deploys are
42
+ > consequential. The deployment target is swappable: this is the \`cloudflare\` provider.
43
+ `;
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @suluk/cockpit — the PURE cockpit core: the cycle model, the builder model, codegen, deploy planning, and
3
+ * the validate/audit/preview helpers. No host API. Two shells consume this exact core: the vscode extension
4
+ * (suluk-vscode) and the web admin panel (@suluk/admin, served under /superadmin). One brain, two faces.
5
+ * CANDIDATE tooling — NOT official OAS.
6
+ */
7
+ export { validateSource, auditSource, previewHtml, looksLikeV4, type Diagnostic } from "./logic";
8
+ export { buildCycle, docChecks, cycleSummary, type CycleModel, type CycleLayer, type CycleItem, type LayerStatus, type Principal, type DocCheck } from "./cycle";
9
+ export { buildBuilderModel, builderTree, entitiesFromDoc, generateAppFiles, generateRegistryJson, type BuilderModel, type BuilderNode, type GeneratedFile } from "./builder";
10
+ export { entityNames, generateForm, generateTable, generateStoresModule, exportV4Json } from "./codegen";
11
+ export { deployPlan, deployMarkdown } from "./deploy";
12
+ export type { DeployPlan, DeployStep, DeployProvider } from "@suluk/deploy";
package/src/logic.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pure extension logic — the part that does NOT touch the vscode API, so it is unit-testable with bun.
3
+ * The thin vscode shell (extension.ts) wires editor events + webviews to these functions. This separation
4
+ * is the same pattern as @suluk/better-auth (pure functions + a duck-typed adapter).
5
+ */
6
+ import { parseDocument, validateDocument } from "@suluk/core";
7
+ import type { OpenAPIv4Document } from "@suluk/core";
8
+ import { audit, type Finding } from "@suluk/hono";
9
+ import { scalarHtml } from "@suluk/scalar";
10
+ import { swaggerHtml } from "@suluk/swagger";
11
+
12
+ export interface Diagnostic {
13
+ severity: "error" | "warning" | "info";
14
+ path: string;
15
+ message: string;
16
+ }
17
+
18
+ /** True if a parsed document looks like a v4 "Suluk" document (so we only act on those). */
19
+ export function looksLikeV4(doc: unknown): doc is OpenAPIv4Document {
20
+ return !!doc && typeof doc === "object" && typeof (doc as { openapi?: unknown }).openapi === "string" && (doc as { openapi: string }).openapi.startsWith("4");
21
+ }
22
+
23
+ /** Parse + meta-schema validate a document source. Parse failure → a single error diagnostic. */
24
+ export function validateSource(text: string): { ok: boolean; diagnostics: Diagnostic[] } {
25
+ let doc: OpenAPIv4Document;
26
+ try {
27
+ doc = parseDocument(text);
28
+ } catch (e) {
29
+ return { ok: false, diagnostics: [{ severity: "error", path: "/", message: `parse error: ${(e as Error).message}` }] };
30
+ }
31
+ if (!looksLikeV4(doc)) {
32
+ return { ok: false, diagnostics: [{ severity: "info", path: "/openapi", message: "not an OpenAPI v4 'Suluk' document (openapi must start with \"4\")" }] };
33
+ }
34
+ const r = validateDocument(doc);
35
+ return {
36
+ ok: r.valid,
37
+ diagnostics: r.errors.map((e) => ({ severity: "error" as const, path: e.path, message: e.message })),
38
+ };
39
+ }
40
+
41
+ /** Documentation-coverage audit (under-documented routes) via the @suluk/hono engine. */
42
+ export function auditSource(text: string): { findings: Finding[]; diagnostics: Diagnostic[] } {
43
+ let doc: OpenAPIv4Document;
44
+ try {
45
+ doc = parseDocument(text);
46
+ } catch {
47
+ return { findings: [], diagnostics: [] };
48
+ }
49
+ if (!looksLikeV4(doc)) return { findings: [], diagnostics: [] };
50
+ const findings = audit(doc);
51
+ return {
52
+ findings,
53
+ diagnostics: findings.map((f) => ({
54
+ severity: f.severity === "warn" ? "warning" : "info",
55
+ path: `${f.path}/${f.operation}`,
56
+ message: `${f.code}: ${f.message}`,
57
+ })),
58
+ };
59
+ }
60
+
61
+ /** Build a self-contained preview page (Scalar or Swagger) for a v4 source. Returns html + downgrade diagnostics. */
62
+ export function previewHtml(text: string, ui: "scalar" | "swagger"): { html: string; diagnostics: { message: string }[] } {
63
+ const doc = parseDocument(text);
64
+ if (!looksLikeV4(doc)) throw new Error("not an OpenAPI v4 'Suluk' document");
65
+ const r = ui === "scalar" ? scalarHtml(doc) : swaggerHtml(doc);
66
+ return { html: r.html, diagnostics: r.diagnostics.map((d) => ({ message: `${d.kind}: ${d.message}` })) };
67
+ }
@@ -0,0 +1,73 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseDocument } from "@suluk/core";
5
+ import { buildBuilderModel, entitiesFromDoc, generateAppFiles, generateRegistryJson, type BuilderNode } from "../src/builder";
6
+
7
+ const petstore = parseDocument(
8
+ readFileSync(join(import.meta.dir, "..", "..", "core", "test", "conformance", "valid", "01-petstore.yaml"), "utf8"),
9
+ );
10
+
11
+ function find(nodes: BuilderNode[], tier: string, label?: string): BuilderNode | undefined {
12
+ for (const n of nodes) {
13
+ if (n.tier === tier && (!label || n.label === label)) return n;
14
+ const inner = find(n.children, tier, label);
15
+ if (inner) return inner;
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ describe("buildBuilderModel — the cockpit Builder surface", () => {
21
+ const model = buildBuilderModel(petstore);
22
+
23
+ test("treats each schema as an entity (Pet, Category, Tag)", () => {
24
+ expect(entitiesFromDoc(petstore).map((e) => e.name).sort()).toEqual(["Category", "Pet", "Tag"]);
25
+ expect(model.entityCount).toBe(3);
26
+ });
27
+
28
+ test("the composition is sound", () => {
29
+ expect(model.errors).toEqual([]);
30
+ });
31
+
32
+ test("the tree is pages → sections → blocks → components", () => {
33
+ const page = model.tree[0];
34
+ expect(page.tier).toBe("page");
35
+ const section = find(model.tree, "section", "PetCrud")!;
36
+ expect(section.children.map((c) => c.label).sort()).toEqual(["PetForm", "PetTable"]);
37
+ const block = find(model.tree, "block", "PetForm")!;
38
+ expect(block.children[0].tier).toBe("component"); // ShadcnForm
39
+ });
40
+
41
+ test("each tier carries its param contract (the narrowing is visible)", () => {
42
+ const page = find(model.tree, "page")!;
43
+ const section = find(model.tree, "section", "PetCrud")!;
44
+ const block = find(model.tree, "block", "PetForm")!;
45
+ // a page may set { tone, sections }; a section { tone, blocks }; a form block { tone, fields }
46
+ expect(page.contract.sort()).toEqual(["sections", "tone"]);
47
+ expect(section.contract.sort()).toEqual(["blocks", "tone"]);
48
+ expect(block.contract).toContain("fields");
49
+ // the narrowing: `fields` is in the BLOCK contract but NOT the section/page contract
50
+ expect(section.contract).not.toContain("fields");
51
+ expect(page.contract).not.toContain("fields");
52
+ });
53
+ });
54
+
55
+ describe("generate actions land artifacts", () => {
56
+ test("generateAppFiles emits the v4 doc, frontend components/pages, and the shadcn registry", () => {
57
+ const files = generateAppFiles(petstore);
58
+ const paths = files.map((f) => f.path);
59
+ expect(paths).toContain("openapi.json");
60
+ expect(paths).toContain("components/PetForm.tsx");
61
+ expect(paths.some((p) => p.startsWith("app/") && p.endsWith("page.tsx"))).toBe(true);
62
+ expect(paths).toContain("registry.json");
63
+ expect(paths.some((p) => p === "registry/pet-crud.json")).toBe(true);
64
+ });
65
+
66
+ test("generateRegistryJson packages each slice (UI + backend) as installable items", () => {
67
+ const reg = JSON.parse(generateRegistryJson(petstore)) as { items: { name: string; files: { path: string }[] }[] };
68
+ const petCrud = reg.items.find((i) => i.name === "pet-crud")!;
69
+ const filePaths = petCrud.files.map((f) => f.path);
70
+ expect(filePaths).toContain("components/PetForm.tsx"); // UI
71
+ expect(filePaths).toContain("server/pet.routes.ts"); // backend, in the same install unit
72
+ });
73
+ });
@@ -0,0 +1,115 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseDocument } from "@suluk/core";
5
+ import { buildCycle, cycleSummary, docChecks } from "../src/cycle";
6
+ import { entityNames, generateForm, generateTable, generateStoresModule, exportV4Json } from "../src/codegen";
7
+
8
+ const petstoreSrc = readFileSync(
9
+ join(import.meta.dir, "..", "..", "core", "test", "conformance", "valid", "01-petstore.yaml"),
10
+ "utf8",
11
+ );
12
+ const petstore = parseDocument(petstoreSrc);
13
+
14
+ describe("buildCycle — the cockpit spine (every layer from one hub)", () => {
15
+ const model = buildCycle(petstore);
16
+
17
+ test("models all nine layers (incl. cost)", () => {
18
+ expect(model.layers.map((l) => l.id)).toEqual(["data", "contract", "auth", "document", "cost", "docs", "state", "ui", "tests"]);
19
+ });
20
+ test("the cost layer reflects declared costs (x-suluk-cost) — none on the bare petstore", () => {
21
+ expect(model.layers.find((l) => l.id === "cost")!.summary).toBe("no costs declared");
22
+ });
23
+ test("data layer lists the entities (Pet, Category, Tag)", () => {
24
+ const data = model.layers.find((l) => l.id === "data")!;
25
+ expect(data.items.map((i) => i.label).sort()).toEqual(["Category", "Pet", "Tag"]);
26
+ });
27
+ test("contract layer lists operations and flags scope-gated ones", () => {
28
+ const contract = model.layers.find((l) => l.id === "contract")!;
29
+ const names = contract.items.map((i) => i.label);
30
+ expect(names).toContain("createPet");
31
+ expect(names).toContain("listPets");
32
+ // createPet requires write:pets → flagged info; listPets is public
33
+ expect(contract.items.find((i) => i.label === "createPet")!.status).toBe("info");
34
+ expect(contract.items.find((i) => i.label === "listPets")!.status).toBeUndefined();
35
+ });
36
+ test("auth layer lists the security schemes", () => {
37
+ const auth = model.layers.find((l) => l.id === "auth")!;
38
+ expect(auth.items.map((i) => i.label).sort()).toEqual(["api_key", "petstore_auth"]);
39
+ });
40
+ test("document layer reports validity + coverage", () => {
41
+ expect(model.valid).toBe(true);
42
+ const docLayer = model.layers.find((l) => l.id === "document")!;
43
+ expect(docLayer.summary).toContain("meta-schema ✓");
44
+ });
45
+ test("state layer derives a store per operation (GET → fetcher, else mutator)", () => {
46
+ const state = model.layers.find((l) => l.id === "state")!;
47
+ expect(state.items.find((i) => i.label === "listPets")!.detail).toBe("fetcher store");
48
+ expect(state.items.find((i) => i.label === "createPet")!.detail).toBe("mutator store");
49
+ });
50
+ test("ui layer derives a form+table per entity", () => {
51
+ const ui = model.layers.find((l) => l.id === "ui")!;
52
+ expect(ui.items.find((i) => i.label === "Pet")!.detail).toMatch(/form \(\d+ fields\)/);
53
+ });
54
+ test("tests layer runs doc-level contract checks (all pass for petstore)", () => {
55
+ const tests = model.layers.find((l) => l.id === "tests")!;
56
+ expect(tests.status).toBe("ok");
57
+ expect(tests.items.every((i) => i.status === "ok")).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe("the cockpit is a FUNCTION of the viewer (per-WHO projection)", () => {
62
+ test("anonymous hides scope-gated operations; a scoped principal reveals them", () => {
63
+ const anon = buildCycle(petstore, { principal: { scopes: [] } });
64
+ const writer = buildCycle(petstore, { principal: { scopes: ["write:pets"] } });
65
+ const anonOps = anon.layers.find((l) => l.id === "contract")!.items.map((i) => i.label);
66
+ const writerOps = writer.layers.find((l) => l.id === "contract")!.items.map((i) => i.label);
67
+ expect(anonOps).toContain("listPets"); // public
68
+ expect(anonOps).not.toContain("createPet"); // requires write:pets
69
+ expect(writerOps).toContain("createPet");
70
+ // the hidden count is surfaced honestly
71
+ expect(anon.layers.find((l) => l.id === "contract")!.summary).toContain("hidden for this viewer");
72
+ });
73
+ test("no principal ⇒ full view", () => {
74
+ const full = buildCycle(petstore);
75
+ expect(full.layers.find((l) => l.id === "contract")!.items.map((i) => i.label)).toContain("createPet");
76
+ });
77
+ });
78
+
79
+ describe("docChecks — the doc as an executable check", () => {
80
+ test("petstore passes all doc-level checks", () => {
81
+ expect(docChecks(petstore).every((c) => c.pass)).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe("codegen actions land real artifacts", () => {
86
+ test("entityNames lists the schemas", () => {
87
+ expect(entityNames(petstore).sort()).toEqual(["Category", "Pet", "Tag"]);
88
+ });
89
+ test("generateForm emits a shadcn form TSX for Pet", () => {
90
+ const tsx = generateForm(petstore, "Pet");
91
+ expect(tsx).toContain("useForm");
92
+ expect(tsx).toContain("zodResolver");
93
+ expect(tsx).toContain("FormField");
94
+ expect(tsx).toContain("PetForm");
95
+ });
96
+ test("generateTable emits a shadcn table TSX for Pet", () => {
97
+ expect(generateTable(petstore, "Pet")).toContain("<Table");
98
+ });
99
+ test("generateStoresModule wires createApiStores and lists derived stores", () => {
100
+ const mod = generateStoresModule(petstore);
101
+ expect(mod).toContain("createApiStores");
102
+ expect(mod).toContain("@suluk/nano-stores");
103
+ expect(mod).toContain("createPet"); // a derived store listed in the comment
104
+ });
105
+ test("exportV4Json round-trips the document to JSON", () => {
106
+ const json = JSON.parse(exportV4Json(petstoreSrc));
107
+ expect(json.openapi).toContain("4.");
108
+ });
109
+ });
110
+
111
+ describe("cycleSummary — flat status line", () => {
112
+ test("returns one entry per layer", () => {
113
+ expect(cycleSummary(buildCycle(petstore)).length).toBe(9);
114
+ });
115
+ });
@@ -0,0 +1,32 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseDocument } from "@suluk/core";
5
+ import { deployPlan, deployMarkdown } from "../src/deploy";
6
+
7
+ const petstore = parseDocument(
8
+ readFileSync(join(import.meta.dir, "..", "..", "core", "test", "conformance", "valid", "01-petstore.yaml"), "utf8"),
9
+ );
10
+
11
+ describe("deployPlan — the cockpit's Cloudflare deploy surface", () => {
12
+ const plan = deployPlan(petstore);
13
+
14
+ test("produces the wrangler config, worker entry, and D1 schema from the doc's entities", () => {
15
+ const paths = plan.files.map((f) => f.path).sort();
16
+ expect(paths).toEqual(["schema.sql", "worker.ts", "wrangler.jsonc"]);
17
+ expect(plan.files.find((f) => f.path === "schema.sql")!.content).toContain("CREATE TABLE IF NOT EXISTS pet");
18
+ });
19
+
20
+ test("the ordered steps start with auth and end with deploy", () => {
21
+ expect(plan.steps[0].cmd).toBe("wrangler login");
22
+ expect(plan.steps.at(-1)!.cmd).toBe("wrangler deploy");
23
+ });
24
+
25
+ test("deployMarkdown renders a followable DEPLOY.md", () => {
26
+ const md = deployMarkdown(plan);
27
+ expect(md).toContain("# Deploy to Cloudflare");
28
+ expect(md).toContain("wrangler login");
29
+ expect(md).toContain("wrangler.jsonc");
30
+ expect(md).toContain("swappable");
31
+ });
32
+ });
@@ -0,0 +1,68 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { validateSource, auditSource, previewHtml, looksLikeV4 } from "../src/logic";
5
+ import { parseDocument } from "@suluk/core";
6
+
7
+ const petstoreSrc = readFileSync(
8
+ join(import.meta.dir, "..", "..", "core", "test", "conformance", "valid", "01-petstore.yaml"),
9
+ "utf8",
10
+ );
11
+
12
+ describe("validateSource — the editor's diagnostics source", () => {
13
+ test("a valid v4 document yields no error diagnostics", () => {
14
+ const r = validateSource(petstoreSrc);
15
+ expect(r.ok).toBe(true);
16
+ expect(r.diagnostics).toEqual([]);
17
+ });
18
+ test("a malformed v4 document yields error diagnostics", () => {
19
+ const bad = `openapi: 4.0.0-candidate\ninfo: { title: t, version: "1" }\npaths:\n "pet":\n requests:\n x: { responses: { ok: { status: 200 } } }\n`; // request missing `method`
20
+ const r = validateSource(bad);
21
+ expect(r.ok).toBe(false);
22
+ expect(r.diagnostics.length).toBeGreaterThan(0);
23
+ });
24
+ test("a YAML parse error becomes a single error diagnostic", () => {
25
+ const r = validateSource("openapi: 4.0\n bad: : :\n:");
26
+ expect(r.ok).toBe(false);
27
+ expect(r.diagnostics[0].severity).toBe("error");
28
+ });
29
+ test("a non-v4 document is recognized and skipped (info, not error)", () => {
30
+ const r = validateSource(`openapi: 3.1.0\ninfo: { title: t, version: "1" }\npaths: {}`);
31
+ expect(r.ok).toBe(false);
32
+ expect(r.diagnostics[0].severity).toBe("info");
33
+ });
34
+ });
35
+
36
+ describe("looksLikeV4", () => {
37
+ test("accepts a 4.x document, rejects 3.x and junk", () => {
38
+ expect(looksLikeV4(parseDocument(petstoreSrc))).toBe(true);
39
+ expect(looksLikeV4({ openapi: "3.1.0" })).toBe(false);
40
+ expect(looksLikeV4({})).toBe(false);
41
+ expect(looksLikeV4(null)).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("auditSource — documentation coverage in the editor", () => {
46
+ test("returns findings as warning/info diagnostics for an under-documented doc", () => {
47
+ const sparse = `openapi: 4.0.0-candidate\ninfo: { title: t, version: "1" }\npaths:\n "x":\n requests:\n doThing: { method: get, responses: { ok: { status: 200 } } }\n`;
48
+ const { findings, diagnostics } = auditSource(sparse);
49
+ expect(findings.length).toBeGreaterThan(0);
50
+ expect(diagnostics.some((d) => d.severity === "warning")).toBe(true); // missing-doc
51
+ });
52
+ });
53
+
54
+ describe("previewHtml — the webview content", () => {
55
+ test("scalar preview embeds the spec + loads Scalar", () => {
56
+ const { html } = previewHtml(petstoreSrc, "scalar");
57
+ expect(html).toContain("Scalar.createApiReference");
58
+ expect(html).toContain('"openapi":"3.1.0"');
59
+ });
60
+ test("swagger preview embeds the spec + loads Swagger UI", () => {
61
+ const { html } = previewHtml(petstoreSrc, "swagger");
62
+ expect(html).toContain("SwaggerUIBundle");
63
+ expect(html).toContain("swagger-ui-dist");
64
+ });
65
+ test("previewing a non-v4 doc throws a clear error", () => {
66
+ expect(() => previewHtml(`openapi: 3.1.0\ninfo: { title: t, version: "1" }\npaths: {}`, "scalar")).toThrow();
67
+ });
68
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": { "types": ["bun"] },
4
+ "include": ["src", "test"]
5
+ }