feat(cli): add wrnexus authz list/generate/init

Introspects the merged authz catalog, emits app/authz/permissions.gen.ts
type unions, and scaffolds the assignment-table migration. init validates
--dialect explicitly (unrecognised values reject rather than silently
falling back to sqlite) and joins authzMigrationSql's up/down statement
lists with terminators instead of interpolating the arrays.

Test scaffolding for dynamically-imported app/authz declarations must
live inside the repo tree (not os.tmpdir()) for the "@wrnexus/*" bare
specifier to resolve via tsconfig paths; .gitignore excludes the scratch
dirs this produces.
This commit is contained in:
2026-08-04 21:34:56 +05:30
parent e5d0654d2a
commit b9098382b3
4 changed files with 325 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
/**
* `wrnexus authz <cmd>` — authorization catalog tooling.
*
* wrnexus authz list print every registered permission, role, and policy
* wrnexus authz generate write app/authz/permissions.gen.ts type unions
* wrnexus authz init [--dialect=sqlite|postgres|mysql]
* scaffold the assignment-table migration
*/
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { buildRouter } from "@wrnexus/router";
import {
generatePermissionTypes,
mergeCatalogs,
type AuthzCatalog,
type AuthzModule,
type CatalogSource,
} from "@wrnexus/authz";
import { authzMigrationSql } from "@wrnexus/authz/db";
import type { Dialect } from "@wrnexus/db";
const USAGE = "usage: wrnexus authz <list|generate|init>";
const DIALECTS = ["sqlite", "postgres", "mysql"] as const;
/** Import every app/authz declaration and merge it into one catalog. */
export async function loadAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
const router = buildRouter(appDir);
const sources: CatalogSource[] = [];
for (const entry of router.authz) {
const imported = (await import(pathToFileURL(entry.file).href)) as {
default?: AuthzModule;
};
if (!imported.default) {
console.warn(`[wrnexus] ${entry.file} has no default export; skipping`);
continue;
}
sources.push({ source: entry.file, module: imported.default });
}
return mergeCatalogs(sources);
}
function nextMigrationNumber(dir: string): string {
if (!existsSync(dir)) return "0001";
const numbers = readdirSync(dir)
.map((name) => Number.parseInt(name.slice(0, 4), 10))
.filter((value) => Number.isInteger(value));
return String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, "0");
}
/** Parse `--dialect=<value>` from CLI args. Defaults to sqlite; rejects unknown values. */
function resolveDialect(args: string[]): Dialect {
const flag = args.find((arg) => arg.startsWith("--dialect="));
if (!flag) return "sqlite";
const value = flag.split("=")[1];
if ((DIALECTS as readonly string[]).includes(value ?? "")) return value as Dialect;
throw new Error(
`WRN-AUTHZ-INIT: unrecognised --dialect='${value}'. Use one of: ${DIALECTS.join(", ")}.`,
);
}
export async function runAuthzCommand(
root: string,
sub: string | undefined,
args: string[],
): Promise<void> {
const appDir = join(resolve(root), "app");
switch (sub) {
case "list": {
const catalog = await loadAuthzCatalog(appDir);
console.log(`Permissions (${catalog.permissions.size}):`);
for (const [id, meta] of [...catalog.permissions].sort()) {
const tags = [meta.risk && `risk=${meta.risk}`, meta.public && "public"]
.filter(Boolean)
.join(" ");
console.log(` ${id}${meta.title ? `${meta.title}` : ""}${tags ? ` [${tags}]` : ""}`);
}
console.log(`\nRoles (${catalog.roles.size}):`);
for (const [name, grants] of [...catalog.roles].sort()) {
console.log(` ${name}${grants.join(", ") || "(nothing)"}`);
}
console.log(`\nPolicies (${catalog.policies.size}):`);
for (const name of [...catalog.policies.keys()].sort()) {
const bound = [...catalog.bindings]
.filter(([, names]) => names.includes(name))
.map(([permission]) => permission);
console.log(` ${name}${bound.length ? `${bound.join(", ")}` : " (unbound)"}`);
}
return;
}
case "generate": {
const catalog = await loadAuthzCatalog(appDir);
const target = join(appDir, "authz", "permissions.gen.ts");
mkdirSync(join(appDir, "authz"), { recursive: true });
writeFileSync(target, generatePermissionTypes(catalog), "utf8");
console.log(
`Wrote ${target} (${catalog.permissions.size} permissions, ${catalog.roles.size} roles)`,
);
return;
}
case "init": {
const dialect = resolveDialect(args);
const dir = join(appDir, "db", "migrations");
mkdirSync(dir, { recursive: true });
const { up, down } = authzMigrationSql(dialect);
const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`);
// up/down are statement LISTS; interpolating the arrays directly would
// comma-join them into one unparseable statement.
const block = (statements: string[]) => statements.map((s) => `${s};`).join("\n\n");
writeFileSync(file, `-- +up\n${block(up)}\n\n-- +down\n${block(down)}\n`, "utf8");
console.log(`Wrote ${file}`);
console.log("Run `wrnexus db migrate` to apply it.");
return;
}
default:
throw new Error(USAGE);
}
}
+9
View File
@@ -7,6 +7,7 @@
* wrnexus create <app-name> scaffold a new app
* wrnexus eject <name...> copy a Wire UI component into your app
* wrnexus db <migrate|rollback|status|new> database migrations
* wrnexus authz <list|generate|init> authorization catalog tooling
*/
import { join, resolve } from "node:path";
@@ -66,6 +67,7 @@ Usage:
wrnexus eject <name...> Copy a Wire UI component into app/components
wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
wrnexus authz <cmd> Authorization: list | generate | init [--dialect=sqlite|postgres|mysql]
wrnexus test [level] [app-dir] [--watch]
Run unit | component | api | browser | visual | accessibility | performance
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
@@ -270,6 +272,13 @@ async function main(): Promise<void> {
await runDbCommand(".", sub, dbArgs);
break;
}
case "authz": {
bootstrapProfile(".", "development", rest);
const { runAuthzCommand } = await import("./authz.ts");
const [sub, ...authzArgs] = rest.filter((a) => !a.startsWith("--profile="));
await runAuthzCommand(".", sub, authzArgs);
break;
}
case "profiles": {
const { listProfiles } = await import("./profiles.ts");
await listProfiles(rest.find((a) => !a.startsWith("--")) ?? ".");