feat: add application productivity foundations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.54",
|
||||
"version": "0.8.55",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { runGenerateResource } from "./generate.ts";
|
||||
|
||||
const FEATURES: Record<string, Record<string, string>> = {
|
||||
queue: {
|
||||
"app/queues/example.ts": `import { defineQueue } from "@wrnexus/queue";\n\nexport default defineQueue({\n name: "example",\n jobs: {\n run: {\n run: async (data: { id: string }) => { console.log("job", data.id); },\n success: async (data) => { console.log("completed", data.id); },\n failed: async (data, error) => { console.error("failed", data.id, error); },\n },\n },\n});\n`,
|
||||
},
|
||||
mail: {
|
||||
"app/lib/mail.ts": `import { defineMail } from "@wrnexus/mail";\n\nexport const mail = defineMail({\n from: process.env.MAIL_FROM,\n driver: { send: async () => { throw new Error("Configure a mail driver"); } },\n sandbox: { enabled: process.env.NODE_ENV !== "production", allowlist: [] },\n});\n`,
|
||||
},
|
||||
seed: {
|
||||
"app/db/seed.ts": `import { addSeedData, defineSeed } from "@wrnexus/db";\n\nexport default defineSeed(async (db) => {\n await addSeedData(db, { name: "Example" }, "examples", { conflict: "ignore" });\n});\n`,
|
||||
},
|
||||
testing: {
|
||||
"test/app.test.ts": `import { expect, test } from "bun:test";\nimport { createTestApp } from "@wrnexus/test";\n\ntest("app boots", async () => {\n const app = await createTestApp(import.meta.dir + "/..");\n expect((await app.fetch("/healthz")).ok).toBe(true);\n app.close();\n});\n`,
|
||||
},
|
||||
};
|
||||
|
||||
export function runAdd(root: string, feature: string | undefined, name?: string) {
|
||||
if (feature === "resource") return runGenerateResource(root, name);
|
||||
const files = feature ? FEATURES[feature] : undefined;
|
||||
if (!files)
|
||||
throw new Error(`Use wrnexus add ${Object.keys(FEATURES).join(" | ")} | resource <name>`);
|
||||
for (const [relative, content] of Object.entries(files)) {
|
||||
const target = join(resolve(root), relative);
|
||||
if (existsSync(target)) {
|
||||
console.log(`• ${relative} exists — skipped`);
|
||||
continue;
|
||||
}
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, content, "utf8");
|
||||
console.log(`✓ Added ${relative}`);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { runAdd } from "./add.ts";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
import { currentCliVersion } from "./update-notifier.ts";
|
||||
|
||||
@@ -672,13 +673,26 @@ export function scaffoldApp(root: string, appName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp(name: string): void {
|
||||
export function createApp(name: string, options: readonly string[] = []): void {
|
||||
if (!name) {
|
||||
console.error("Usage: wrnexus create <app-name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
scaffoldApp(resolve(process.cwd(), name), name);
|
||||
const root = resolve(process.cwd(), name);
|
||||
scaffoldApp(root, name);
|
||||
const preset = options.find((value) => value.startsWith("--preset="))?.split("=")[1];
|
||||
const requested =
|
||||
options
|
||||
.find((value) => value.startsWith("--features="))
|
||||
?.split("=")[1]
|
||||
?.split(",") ?? [];
|
||||
const features = new Set([
|
||||
...(preset === "saas" ? ["queue", "mail", "seed", "testing"] : []),
|
||||
...(preset === "email" ? ["queue", "mail", "testing"] : []),
|
||||
...requested,
|
||||
]);
|
||||
for (const feature of features) runAdd(root, feature);
|
||||
|
||||
console.log(`✓ Created ${name}`);
|
||||
console.log(`\nNext steps:`);
|
||||
|
||||
@@ -30,6 +30,46 @@ export interface GeneratedFile {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function scaffoldResource(name: string): GeneratedFile[] {
|
||||
const clean = name.replace(/[^A-Za-z0-9_-]/g, "").toLowerCase();
|
||||
if (!clean) throw new TypeError("resource name is required");
|
||||
const singular = clean.replace(/s$/, "");
|
||||
const pascal = toPascalCase(singular);
|
||||
return [
|
||||
{
|
||||
path: `schemas/${singular}.ts`,
|
||||
content: `import { v } from "@wrnexus/validation";\n\nexport default v.object({\n name: v.string().min(1, "Required"),\n});\n`,
|
||||
},
|
||||
{
|
||||
path: `resources/${clean}.ts`,
|
||||
content: `import { getDb } from "@wrnexus/db";\nimport { defineResource } from "@wrnexus/helpers";\n\nexport default defineResource({\n name: "${clean}",\n db: getDb,\n table: "${clean}",\n owner: "owner_id",\n fields: ["name"],\n permissions: { read: "${singular}:read", write: "${singular}:write" },\n});\n`,
|
||||
},
|
||||
{
|
||||
path: `api/${clean}.ts`,
|
||||
content: `import resource from "../resources/${clean}.ts";\nexport const GET = resource.list;\nexport const POST = resource.create;\n`,
|
||||
},
|
||||
{
|
||||
path: `api/${clean}/[id].ts`,
|
||||
content: `import resource from "../../resources/${clean}.ts";\nexport const GET = resource.get;\nexport const PUT = resource.update;\nexport const DELETE = resource.remove;\n`,
|
||||
},
|
||||
{
|
||||
path: `pages/${clean}.wrn`,
|
||||
content: `page ${pascal}List {\n view {\n <h1>${pascal}</h1>\n <p>Generated resource page. Connect it to /api/${clean}.</p>\n }\n}\n`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function runGenerateResource(appRoot: string, name: string | undefined): void {
|
||||
if (!name) throw new TypeError("Usage: wrnexus generate resource <name>");
|
||||
for (const file of scaffoldResource(name)) {
|
||||
const target = join(resolve(appRoot), "app", file.path);
|
||||
if (existsSync(target)) continue;
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, file.content, "utf8");
|
||||
console.log(`✓ Generated app/${file.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toPascalCase(name: string): string {
|
||||
return name
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
|
||||
@@ -167,7 +167,7 @@ async function main(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case "create":
|
||||
createApp(rest[0] ?? "");
|
||||
createApp(rest.find((value) => !value.startsWith("--")) ?? "", rest);
|
||||
break;
|
||||
case "workspace": {
|
||||
const { addWorkspaceApp, createWorkspace } = await import("./workspace.ts");
|
||||
@@ -192,6 +192,11 @@ async function main(): Promise<void> {
|
||||
}
|
||||
case "generate":
|
||||
case "g": {
|
||||
if (rest[0] === "resource") {
|
||||
const { runGenerateResource } = await import("./generate.ts");
|
||||
runGenerateResource(".", rest[1]);
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "routes") {
|
||||
const { regenerateRoutes } = await import("./routes.ts");
|
||||
const n = regenerateRoutes(join(resolve(rest[1] ?? "."), "app"));
|
||||
@@ -307,6 +312,21 @@ async function main(): Promise<void> {
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "queue": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
const { runQueueCommand } = await import("./queue-command.ts");
|
||||
await runQueueCommand(values[2] ?? ".", values[0] ?? "status", [
|
||||
values[1] ?? "",
|
||||
...rest.filter((value) => value.startsWith("--")),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
case "add": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
const { runAdd } = await import("./add.ts");
|
||||
runAdd(values[2] ?? ".", values[0], values[1]);
|
||||
break;
|
||||
}
|
||||
case "security": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
const { runSecurityCommand } = await import("./security-command.ts");
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { resolve } from "node:path";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import { installSqliteQueueSchema } from "@wrnexus/queue";
|
||||
|
||||
export async function runQueueCommand(root: string, command: string, args: readonly string[]) {
|
||||
const appRoot = resolve(root);
|
||||
const config = await loadAppConfig(appRoot, process.env.WRNEXUS_PROFILE);
|
||||
const selected = config.queue?.databaseName ?? "default";
|
||||
const dbConfig = selected === "default" ? config.db : config.databases?.[selected];
|
||||
const effective =
|
||||
config.queue?.storage === "database"
|
||||
? dbConfig
|
||||
: { driver: "sqlite" as const, url: "file:./.wrnexus/queue.sqlite" };
|
||||
if (!effective || effective.driver === "mongo")
|
||||
throw new Error(`Queue database '${selected}' is not configured as SQL.`);
|
||||
const db = connectFromConfig(effective, appRoot);
|
||||
if (db.driver.dialect !== "sqlite")
|
||||
throw new Error("Queue CLI currently requires SQLite configured storage.");
|
||||
const table = config.queue?.table ?? "wrnexus_jobs";
|
||||
await installSqliteQueueSchema(db, table);
|
||||
try {
|
||||
const id = args.find((value) => !value.startsWith("--"));
|
||||
if (command === "list") {
|
||||
console.table(
|
||||
await db.all(
|
||||
`SELECT id,name,run_at,priority,lease_owner,lease_until FROM ${table} ORDER BY run_at`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command === "status") {
|
||||
const pending = await db.one<{ total: number }>(`SELECT COUNT(*) AS total FROM ${table}`);
|
||||
const history = await db.one<{ total: number }>(
|
||||
`SELECT COUNT(*) AS total FROM ${table}_history`,
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ pending: Number(pending?.total ?? 0), history: Number(history?.total ?? 0) },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!id && command !== "clear") throw new Error(`wrnexus queue ${command} requires a job id`);
|
||||
if (command === "inspect") {
|
||||
console.log(
|
||||
JSON.stringify(await db.one(`SELECT * FROM ${table} WHERE id = ?`, [id]), null, 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command === "cancel") {
|
||||
await db.exec(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
||||
return;
|
||||
}
|
||||
if (command === "retry") {
|
||||
const row = await db.one<{ payload: string }>(
|
||||
`SELECT payload FROM ${table}_history WHERE id = ? AND state = 'failed'`,
|
||||
[id],
|
||||
);
|
||||
if (!row) throw new Error(`Failed job '${id}' was not found.`);
|
||||
const job = JSON.parse(row.payload) as {
|
||||
name: string;
|
||||
runAt: number;
|
||||
priority: number;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
job.runAt = Date.now();
|
||||
await db.exec(
|
||||
`INSERT INTO ${table} (id,name,payload,run_at,priority,idempotency_key) VALUES (?,?,?,?,?,?)`,
|
||||
[id, job.name, JSON.stringify(job), job.runAt, job.priority, job.idempotencyKey ?? null],
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command === "clear" && args.includes("--completed")) {
|
||||
await db.exec(`DELETE FROM ${table}_history WHERE state = 'completed'`);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"Use queue list | status | inspect <id> | retry <id> | cancel <id> | clear --completed",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { scaffold } from "../src/generate.ts";
|
||||
import { scaffold, scaffoldResource } from "../src/generate.ts";
|
||||
|
||||
test("scaffold page: kebab file, PascalCase page name", () => {
|
||||
const f = scaffold("page", "about-us");
|
||||
@@ -32,3 +32,15 @@ test("scaffold strips extensions from the given name", () => {
|
||||
expect(scaffold("page", "home.wrn").path).toBe("pages/home.wrn");
|
||||
expect(scaffold("api", "ping.ts").path).toBe("api/ping.ts");
|
||||
});
|
||||
|
||||
test("resource scaffold emits schema, handlers, routes and a page", () => {
|
||||
const files = scaffoldResource("templates");
|
||||
expect(files.map((file) => file.path)).toEqual([
|
||||
"schemas/template.ts",
|
||||
"resources/templates.ts",
|
||||
"api/templates.ts",
|
||||
"api/templates/[id].ts",
|
||||
"pages/templates.wrn",
|
||||
]);
|
||||
expect(files[1]?.content).toContain("defineResource");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user