From 3ce127345568fe36c0f45988a83140fbc097db41 Mon Sep 17 00:00:00 2001 From: Kevin FEDYNA Date: Wed, 22 Jan 2025 00:54:43 +0100 Subject: [PATCH 1/4] Started documenting code --- databases/ensure.ts | 10 +++++++++- defaults/interfaces.ts | 9 +++++++++ defaults/makeIndex.ts | 18 ++++++++++++++---- defaults/makePartials.tsx | 27 ++++++++++++++++++++------- routes/(apps)/_layout.tsx | 2 +- routes/_app.tsx | 2 +- routes/_middleware.ts | 24 ++++++++++++++++-------- routes/login.tsx | 2 +- routes/logout.tsx | 2 +- 9 files changed, 72 insertions(+), 24 deletions(-) diff --git a/databases/ensure.ts b/databases/ensure.ts index 1c56375..c1cde16 100644 --- a/databases/ensure.ts +++ b/databases/ensure.ts @@ -1,6 +1,14 @@ import { Database } from "@db/sqlite"; -export default async function ensureDatabases() { +/** + * Ensure database file creation on new server start. + * + * Read all SQL files in init directory and create + * associated SQLite database file. + * + * **Must not be used out of statup use-case.** + */ +export default async function ensureDatabases(): Promise { await Deno.mkdir("databases/data", { recursive: true }); for await (const file of Deno.readDir("databases/init")) { diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index 3189dfe..24a5076 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -1,4 +1,10 @@ import { type RegularTagNode, type TextNode } from "@melvdouc/xml-parser"; +import { AsyncRoute } from "$fresh/src/server/types.ts"; + +export interface State { + isAuthenticated: boolean; + session: CasContent; +} export interface AppProperties { name: string; @@ -48,3 +54,6 @@ export interface LoginJWT { } export type EmptyObject = Record; + +// deno-lint-ignore no-explicit-any +export type Route = AsyncRoute; \ No newline at end of file diff --git a/defaults/makeIndex.ts b/defaults/makeIndex.ts index f4ebd11..19ebc91 100644 --- a/defaults/makeIndex.ts +++ b/defaults/makeIndex.ts @@ -1,12 +1,22 @@ import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { Route, State } from "$root/defaults/interfaces.ts"; +import { ComponentChildren } from "preact"; -export default function makeIndex(basePath: string) { +/** + * Generates index file based on `Index` fresh partial to avoid code duplication. + * @param basePath The base path of the module, should be `import.meta.url!`. + * @returns The `Index` fresh partial that will be displayed by default. + * + * @example + * import makeIndex from "$root/defaults/makeIndex.ts"; + * export default makeIndex(import.meta.dirname!); + */ +export default function makeIndex(basePath: string): Route { return async function Index( request: Request, context: FreshContext, - ) { - const index = (await import(`${basePath}/partials/index.tsx`)).Index; + ): Promise { + const index: Route = (await import(`${basePath}/partials/index.tsx`)).Index; return index(request, context); }; } diff --git a/defaults/makePartials.tsx b/defaults/makePartials.tsx index e6b81af..5df6916 100644 --- a/defaults/makePartials.tsx +++ b/defaults/makePartials.tsx @@ -1,8 +1,12 @@ import { JSX } from "preact"; import { Partial } from "$fresh/runtime.ts"; import { FreshContext, RouteConfig } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { Route, State } from "$root/defaults/interfaces.ts"; +/** + * Gets the `RouteConfig` config object for partial pages. + * @returns The partials config object. + */ export function getPartialsConfig(): RouteConfig { return { skipAppWrapper: true, @@ -10,12 +14,21 @@ export function getPartialsConfig(): RouteConfig { }; } -export function makePartials( - page: ( - request: Request, - context: FreshContext, - ) => Promise, -) { +/** + * Partialize the given page for optimized rendering. + * @param page The partial `Route` object to partialize. + * @returns The partialized version of `page`. + * @example + * // Page defintion... + * async function Page(_request: Request, context: FreshContext) { + * return

My super page!

; + * } + * + * // Partial code that should be at each file's end. + * export const config = getPartialsConfig(); + * export default makePartials(Page); + */ +export function makePartials(page: Route) { return async function WrappedElements( request: Request, context: FreshContext, diff --git a/routes/(apps)/_layout.tsx b/routes/(apps)/_layout.tsx index 0fe56fa..ee3b43e 100644 --- a/routes/(apps)/_layout.tsx +++ b/routes/(apps)/_layout.tsx @@ -1,6 +1,6 @@ import { FreshContext } from "$fresh/server.ts"; import { Partial } from "$fresh/runtime.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; import { AppProperties } from "$root/defaults/interfaces.ts"; import Navbar from "$root/routes/(_islands)/Navbar.tsx"; diff --git a/routes/_app.tsx b/routes/_app.tsx index 7025b97..8b253b8 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -1,5 +1,5 @@ import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; import Header from "$root/routes/(_components)/Header.tsx"; import Footer from "$root/routes/(_components)/Footer.tsx"; diff --git a/routes/_middleware.ts b/routes/_middleware.ts index 3601da1..fbaca27 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -1,7 +1,7 @@ import { FreshContext } from "$fresh/server.ts"; import { getCookies } from "$std/http/cookie.ts"; import { getJwtPayload, isJwtValid } from "@popov/jwt"; -import { CasContent, LoginJWT } from "$root/defaults/interfaces.ts"; +import { CasContent, LoginJWT, State } from "$root/defaults/interfaces.ts"; const PUBLIC_ROUTES = [ "/", @@ -13,21 +13,29 @@ const PUBLIC_ROUTES = [ ]; const jwtKeyCache: Record = {}; +const deleteKey = (user: string) => delete jwtKeyCache[user]; -export interface State { - isAuthenticated: boolean; - session: CasContent; -} - -function isRoutePublic(route: string) { - return PUBLIC_ROUTES.includes(route) || route.match(/\..+$/); +/** + * Checks if the given route is public. + * @param route The route to check. + * @returns `true` if the route is public, `false` otherwise. + */ +function isRoutePublic(route: string): boolean { + return PUBLIC_ROUTES.includes(route) || + !!(route.match(/\..+$/)?.[0] ?? false); } +/** + * Get the given user's key, creating it if not already existing. + * @param user The key's user. + * @returns The user's key. + */ export function getKey(user: string): string { if (!jwtKeyCache[user]) { const keyBuffer = new Uint8Array(32); crypto.getRandomValues(keyBuffer); jwtKeyCache[user] = new TextDecoder().decode(keyBuffer); + setTimeout(deleteKey, 0x75300, user); } return jwtKeyCache[user]; diff --git a/routes/login.tsx b/routes/login.tsx index dbaa622..4bbd1c7 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -1,5 +1,5 @@ import { Handlers } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; import { parse, type RegularTagNode } from "@melvdouc/xml-parser"; import { CasContent, diff --git a/routes/logout.tsx b/routes/logout.tsx index 6111c62..8ebc21b 100644 --- a/routes/logout.tsx +++ b/routes/logout.tsx @@ -1,5 +1,5 @@ import { Handlers } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; import { deleteCookie } from "$std/http/cookie.ts"; const CAS = "https://ident.univ-amu.fr/cas"; From 8a5461827edb9e37a8bda886141643794a962d37 Mon Sep 17 00:00:00 2001 From: Kevin FEDYNA Date: Wed, 22 Jan 2025 11:15:43 +0100 Subject: [PATCH 2/4] Optimized code and wrote documentation --- defaults/interfaces.ts | 13 ++++-- routes/_middleware.ts | 29 +++++++++---- routes/apps.tsx | 27 ++++++++++--- routes/login.tsx | 92 +++++++++++++++++++++++------------------- routes/logout.tsx | 13 ++++-- 5 files changed, 113 insertions(+), 61 deletions(-) diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index 24a5076..a5a6268 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -1,11 +1,18 @@ import { type RegularTagNode, type TextNode } from "@melvdouc/xml-parser"; import { AsyncRoute } from "$fresh/src/server/types.ts"; -export interface State { - isAuthenticated: boolean; +interface AuthenticatedState { + isAuthenticated: true; session: CasContent; } +interface UnauthenticatedState { + isAuthenticated: false; + session: undefined; +} + +export type State = AuthenticatedState | UnauthenticatedState; + export interface AppProperties { name: string; icon: string; @@ -56,4 +63,4 @@ export interface LoginJWT { export type EmptyObject = Record; // deno-lint-ignore no-explicit-any -export type Route = AsyncRoute; \ No newline at end of file +export type Route = AsyncRoute; diff --git a/routes/_middleware.ts b/routes/_middleware.ts index fbaca27..01b449e 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -1,4 +1,4 @@ -import { FreshContext } from "$fresh/server.ts"; +import { FreshContext, MiddlewareHandler } from "$fresh/server.ts"; import { getCookies } from "$std/http/cookie.ts"; import { getJwtPayload, isJwtValid } from "@popov/jwt"; import { CasContent, LoginJWT, State } from "$root/defaults/interfaces.ts"; @@ -41,11 +41,17 @@ export function getKey(user: string): string { return jwtKeyCache[user]; } -export const handler = [ +export const handler: MiddlewareHandler[] = [ + /** + * Check if user is authenticated and add session to context accordingly. + * @param request The HTTP incomming request. + * @param context The Fresh context object with custom `State`. + * @returns The response from the next middleware. + */ async function checkAuthentication( request: Request, context: FreshContext, - ) { + ): Promise { const cookies = getCookies(request.headers); if (!cookies["sessionToken"]) { context.state.isAuthenticated = false; @@ -59,17 +65,26 @@ export const handler = [ cookies["sessionToken"], key, ); - const session: CasContent = - (getJwtPayload(cookies["sessionToken"]) as LoginJWT).user; - context.state.session = session; + if (context.state.isAuthenticated) { + const session: CasContent = + (getJwtPayload(cookies["sessionToken"]) as LoginJWT).user; + context.state.session = session; + } return await context.next(); }, + /** + * Check if page can be accessed with or without authentication. + * Redirect if the page is private and the user isn't authenticated. + * @param request The HTTP incomming request. + * @param context The Fresh context object with `State` set up. + * @returns The response from the next middleware or from the page. + */ async function ensureAuthentication( request: Request, context: FreshContext, - ) { + ): Promise { const url = new URL(request.url); if (!isRoutePublic(url.pathname) && !context.state.isAuthenticated) { diff --git a/routes/apps.tsx b/routes/apps.tsx index 54c4e44..d64cabb 100644 --- a/routes/apps.tsx +++ b/routes/apps.tsx @@ -1,10 +1,24 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; -import { AppProperties } from "$root/defaults/interfaces.ts"; +import { AppProperties, State } from "$root/defaults/interfaces.ts"; import AppNavigator from "$root/routes/(_islands)/AppNavigator.tsx"; -export const handler: Handlers = { - async GET(_request, context) { - const apps: Record = {}; +const apps: Record = {}; + +export const handler: Handlers, State> = { + /** + * Generate the app catalog page from pages. + * Catalog is only computed once, then the cached version is used. + * @param _request The HTTP incomming request. + * @param context The Fresh context with `State`. + * @returns The rendered page with all apps as catalog. + */ + async GET( + _request: Request, + context: FreshContext>, + ): Promise { + if (Object.keys(apps).length != 0) { + return context.render(apps); + } for await (const appDir of Deno.readDir("routes/(apps)")) { if (appDir.isFile) { @@ -26,7 +40,10 @@ export const handler: Handlers = { }; // deno-lint-ignore require-await -export default async function Apps(_request: Request, context: FreshContext) { +export default async function Apps( + _request: Request, + context: FreshContext>, +) { return ( <> diff --git a/routes/login.tsx b/routes/login.tsx index 4bbd1c7..3b1da1e 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -1,4 +1,4 @@ -import { Handlers } from "$fresh/server.ts"; +import { FreshContext, Handlers } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; import { parse, type RegularTagNode } from "@melvdouc/xml-parser"; import { @@ -13,13 +13,23 @@ import { getKey } from "$root/routes/_middleware.ts"; const CAS = "https://ident.univ-amu.fr/cas"; -function getTag(tag: CasTagNode): [string, string] { +/** + * Get the tag node value without "cas:" prefix in name. + * @param tag The CAS tag node. + * @returns The `[name, value]` pair. + */ +function getTag(tag: CasTagNode): [name: string, value: string] { return [ tag.tagName.replace("cas:", ""), tag.children[0].value, ]; } +/** + * Gets the user JWT token with a validity period of one hour. + * @param casResponse The CAS reponse parsed from XML. + * @returns The user JWT session token. + */ function createUserJWT(casResponse: CasResponse): Promise { const nodes = casResponse.children[1].children.map(getTag); const fullUserInfos: Record = {}; @@ -28,7 +38,6 @@ function createUserJWT(casResponse: CasResponse): Promise { if (typeof fullUserInfos[key] == "string") { fullUserInfos[key] = [fullUserInfos[key]]; } - if (Array.isArray(fullUserInfos[key])) { fullUserInfos[key].push(value); } else { @@ -37,12 +46,10 @@ function createUserJWT(casResponse: CasResponse): Promise { }); const now = Math.floor(Date.now() / 1000); - const oneHour = 60 * 60; - const payload: LoginJWT = { iss: "PolyMPR", iat: now, - exp: now + oneHour, + exp: now + 0xe10, aud: "PolyMPR", user: fullUserInfos as unknown as CasContent, }; @@ -51,51 +58,39 @@ function createUserJWT(casResponse: CasResponse): Promise { return createJwt(payload, key); } -// deno-lint-ignore no-explicit-any -export const handler: Handlers = { - async GET(request, context) { +export const handler: Handlers = { + /** + * Handles all CAS protocol requests. + * @param request The incomming HTTP request. + * @param context The Fresh context with `State`. + * @returns The redirect corresponding to each step of the CAS protocol. + */ + async GET( + request: Request, + context: FreshContext, + ): Promise { const url = new URL(request.url); const ticket = url.searchParams.get("ticket"); const service = `${context.url.origin}/login`; - if (ticket) { - const response = await fetch( - `${CAS}/serviceValidate?service=${service}&ticket=${ticket}`, - ); - const body = parse(await response.text()) as [RegularTagNode]; - const casResponse = body[0].children[0] as CasResponse; - - if (casResponse.tagName != "cas:authenticationSuccess") { - return new Response(null, { - status: 302, - headers: { - Location: `${CAS}/login?service=${service}`, - }, - }); - } - - const headers = new Headers(); - - setCookie(headers, { - name: "sessionToken", - value: await createUserJWT(casResponse), - }); - headers.set("Location", "/apps"); - - return new Response(null, { - status: 302, - headers, - }); - } - - if (context.state.isAuthenticated) { + if (!ticket) { return new Response(null, { status: 302, headers: { - Location: "/apps", + Location: context.state.isAuthenticated + ? "/apps" + : `${CAS}/login?service=${service}`, }, }); - } else { + } + + const response = await fetch( + `${CAS}/serviceValidate?service=${service}&ticket=${ticket}`, + ); + const body = parse(await response.text()) as [RegularTagNode]; + const casResponse = body[0].children[0] as CasResponse; + + if (casResponse.tagName != "cas:authenticationSuccess") { return new Response(null, { status: 302, headers: { @@ -103,5 +98,18 @@ export const handler: Handlers = { }, }); } + + const headers = new Headers(); + + setCookie(headers, { + name: "sessionToken", + value: await createUserJWT(casResponse), + }); + headers.set("Location", "/apps"); + + return new Response(null, { + status: 302, + headers, + }); }, }; diff --git a/routes/logout.tsx b/routes/logout.tsx index 8ebc21b..1547942 100644 --- a/routes/logout.tsx +++ b/routes/logout.tsx @@ -1,12 +1,17 @@ -import { Handlers } from "$fresh/server.ts"; +import { FreshContext, Handlers } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; import { deleteCookie } from "$std/http/cookie.ts"; const CAS = "https://ident.univ-amu.fr/cas"; -// deno-lint-ignore no-explicit-any -export const handler: Handlers = { - GET(_request, context) { +export const handler: Handlers = { + /** + * Logout of amU CAS SSO system. + * @param _request The HTTP incomming request. + * @param context The Fresh context with `State`. + * @returns A redirect response to either CAS logout or home. + */ + GET(_request: Request, context: FreshContext): Response { if (context.state.isAuthenticated) { const headers = new Headers(); From 75a9591f6af923d8a125331ff6ded585042f26b8 Mon Sep 17 00:00:00 2001 From: Kevin FEDYNA Date: Wed, 22 Jan 2025 14:24:55 +0100 Subject: [PATCH 3/4] Added documentation on cli --- databases/ensure.ts | 2 +- defaults/makePartials.tsx | 4 +- toolbox/cli.ts | 101 ++++---------------------------------- toolbox/cli/command.ts | 40 +++++++++++++++ toolbox/cli/help.ts | 70 ++++++++++++++++++++++++++ toolbox/cli/main.ts | 14 ++++++ toolbox/module/create.ts | 35 +++++++++++++ toolbox/module/list.ts | 3 ++ 8 files changed, 175 insertions(+), 94 deletions(-) create mode 100644 toolbox/cli/command.ts create mode 100644 toolbox/cli/help.ts create mode 100644 toolbox/cli/main.ts diff --git a/databases/ensure.ts b/databases/ensure.ts index c1cde16..41431f5 100644 --- a/databases/ensure.ts +++ b/databases/ensure.ts @@ -5,7 +5,7 @@ import { Database } from "@db/sqlite"; * * Read all SQL files in init directory and create * associated SQLite database file. - * + * * **Must not be used out of statup use-case.** */ export default async function ensureDatabases(): Promise { diff --git a/defaults/makePartials.tsx b/defaults/makePartials.tsx index 5df6916..90dc7cf 100644 --- a/defaults/makePartials.tsx +++ b/defaults/makePartials.tsx @@ -16,14 +16,14 @@ export function getPartialsConfig(): RouteConfig { /** * Partialize the given page for optimized rendering. - * @param page The partial `Route` object to partialize. + * @param page The partial `Route` object to partialize. * @returns The partialized version of `page`. * @example * // Page defintion... * async function Page(_request: Request, context: FreshContext) { * return

My super page!

; * } - * + * * // Partial code that should be at each file's end. * export const config = getPartialsConfig(); * export default makePartials(Page); diff --git a/toolbox/cli.ts b/toolbox/cli.ts index 91f91f1..015616b 100644 --- a/toolbox/cli.ts +++ b/toolbox/cli.ts @@ -1,15 +1,17 @@ -import { parseArgs, type ParseOptions } from "@std/cli/parse-args"; +import { type ParseOptions } from "@std/cli/parse-args"; import { createModule } from "$root/toolbox/module/create.ts"; import { listModules } from "$root/toolbox/module/list.ts"; +import { CLI, displayHelp } from "$root/toolbox/cli/help.ts"; +import { main } from "$root/toolbox/cli/main.ts"; -type CLICommand = (...args: Array) => void; -type CLIAsyncCommand = (...args: Array) => Promise; -interface CLI { - [command: string]: CLI | CLICommand | CLIAsyncCommand; -} - +/** + * CLI will use `args._`, but you can define options for global CLI. + */ const argSpec: ParseOptions = {}; +/** + * Configure CLI commands here. + */ const cli: CLI = { help: () => displayHelp(cli), module: { @@ -18,87 +20,4 @@ const cli: CLI = { }, }; -function displayHelp( - cli: CLI | CLICommand | CLIAsyncCommand, - errorMessage?: string, -): never { - const loggingFunction = errorMessage ? console.error : console.log; - if (errorMessage) { - console.error(errorMessage); - } - - if (typeof cli == "function") { - loggingFunction("Usage:"); - displayFunctionHelp(cli, loggingFunction); - } else { - loggingFunction("Commands:"); - displayObjectHelp(cli, loggingFunction); - } - - Deno.exit(errorMessage ? 1 : 0); -} - -function displayObjectHelp( - cli: CLI, - loggingFunction: typeof console.log, - level: number = 1, -) { - for (const [key, value] of Object.entries(cli)) { - if (typeof value == "function") { - displayFunctionHelp(value, loggingFunction, level); - } else { - loggingFunction(`${" ".repeat(level * 2)}${key}`); - displayObjectHelp(value, loggingFunction, level + 1); - } - } -} - -function displayFunctionHelp( - cli: CLICommand | CLIAsyncCommand, - loggingFunction: typeof console.log, - level: number = 1, -) { - const command = cli.name; - const matched = cli.toString().match(/(?<=^\()[^\)]+(?=\))/); - const args = matched?.[0].split(",").map((arg) => `<${arg.trim()}>`); - loggingFunction( - `${" ".repeat(level * 2)}${command} ${args?.join(" ") ?? ""}`, - ); -} - -function runCommand( - commands: Array, - cli: CLI, -): never | { - command: CLICommand | CLIAsyncCommand; - args: Array; -} { - if (commands.length == 0) { - displayHelp(cli, `No command provided.`); - } - - const command = commands.shift()!.toString(); - - if (cli[command] == undefined) { - displayHelp(cli, `Command "${command}" doesn't exist.`); - } - - if (typeof cli[command] == "object") { - return runCommand(commands, cli[command]); - } - - if (cli[command].length != commands.length) { - displayHelp(cli[command], `Wrong usage of command "${command}".`); - } - - return { command: cli[command], args: commands }; -} - -function main() { - const argv = parseArgs(Deno.args, argSpec); - const { command, args } = runCommand(argv._, cli); - - command(...args.map((element) => element.toString())); -} - -main(); +main(cli, argSpec); diff --git a/toolbox/cli/command.ts b/toolbox/cli/command.ts new file mode 100644 index 0000000..d9dc830 --- /dev/null +++ b/toolbox/cli/command.ts @@ -0,0 +1,40 @@ +import { + CLI, + CLIAsyncCommand, + CLICommand, + displayHelp, +} from "$root/toolbox/cli/help.ts"; + +/** + * Run the command given by arguments. + * @param commands The given arguments. + * @param cli The CLI (sub-)configuration object. + * @returns The command or the help if commands are not valid. + */ +export function runCommand( + commands: Array, + cli: CLI, +): never | { + command: CLICommand | CLIAsyncCommand; + args: Array; +} { + if (commands.length == 0) { + displayHelp(cli, `No command provided.`); + } + + const command = commands.shift()!.toString(); + + if (cli[command] == undefined) { + displayHelp(cli, `Command "${command}" doesn't exist.`); + } + + if (typeof cli[command] == "object") { + return runCommand(commands, cli[command]); + } + + if (cli[command].length != commands.length) { + displayHelp(cli[command], `Wrong usage of command "${command}".`); + } + + return { command: cli[command], args: commands }; +} diff --git a/toolbox/cli/help.ts b/toolbox/cli/help.ts new file mode 100644 index 0000000..b906dd5 --- /dev/null +++ b/toolbox/cli/help.ts @@ -0,0 +1,70 @@ +export type CLICommand = (...args: Array) => void; +export type CLIAsyncCommand = (...args: Array) => Promise; +export interface CLI { + [command: string]: CLI | CLICommand | CLIAsyncCommand; +} + +/** + * Display the help message for the CLI. + * @param cli The CLI (sub-)configuration object. + * @param errorMessage The error message to display. + */ +export function displayHelp( + cli: CLI | CLICommand | CLIAsyncCommand, + errorMessage?: string, +): never { + const loggingFunction = errorMessage ? console.error : console.log; + if (errorMessage) { + console.error(errorMessage); + } + + if (typeof cli == "function") { + loggingFunction("Usage:"); + displayFunctionHelp(cli, loggingFunction); + } else { + loggingFunction("Commands:"); + displayObjectHelp(cli, loggingFunction); + } + + Deno.exit(errorMessage ? 1 : 0); +} + +/** + * Display object help recursivelly. + * @param cli The CLI (sub-)configuration object. + * @param loggingFunction The logging function to use. + * @param level The tab level. + */ +function displayObjectHelp( + cli: CLI, + loggingFunction: typeof console.log, + level: number = 1, +) { + for (const [key, value] of Object.entries(cli)) { + if (typeof value == "function") { + displayFunctionHelp(value, loggingFunction, level); + } else { + loggingFunction(`${" ".repeat(level * 2)}${key}`); + displayObjectHelp(value, loggingFunction, level + 1); + } + } +} + +/** + * Display function help. + * @param cli The CLI function. + * @param loggingFunction The logging function to use. + * @param level The tab level. + */ +function displayFunctionHelp( + cli: CLICommand | CLIAsyncCommand, + loggingFunction: typeof console.log, + level: number = 1, +) { + const command = cli.name; + const matched = cli.toString().match(/(?<=^\()[^\)]+(?=\))/); + const args = matched?.[0].split(",").map((arg) => `<${arg.trim()}>`); + loggingFunction( + `${" ".repeat(level * 2)}${command} ${args?.join(" ") ?? ""}`, + ); +} diff --git a/toolbox/cli/main.ts b/toolbox/cli/main.ts new file mode 100644 index 0000000..7e57c63 --- /dev/null +++ b/toolbox/cli/main.ts @@ -0,0 +1,14 @@ +import { parseArgs, ParseOptions } from "@std/cli/parse-args"; +import { runCommand } from "$root/toolbox/cli/command.ts"; +import { CLI } from "$root/toolbox/cli/help.ts"; + +/** + * Runs the CLI. + * @param cli The CLI configuration object. + * @param argSpec The Parse options for args. + */ +export function main(cli: CLI, argSpec: ParseOptions) { + const argv = parseArgs(Deno.args, argSpec); + const { command, args } = runCommand(argv._, cli); + command(...args.map((element) => element.toString())); +} diff --git a/toolbox/module/create.ts b/toolbox/module/create.ts index aa93463..1647665 100644 --- a/toolbox/module/create.ts +++ b/toolbox/module/create.ts @@ -1,3 +1,7 @@ +/** + * Creates a new module. + * @param name The module name. + */ export async function createModule(name: string): Promise { if (!name.match(/^[a-zA-Z0-9](?:(?:\-(?!\-))?[a-zA-Z0-9]*)*[a-zA-Z0-9]$/)) { console.error("Module names must be in kebab case."); @@ -58,16 +62,32 @@ export async function createModule(name: string): Promise { formatter.output(); } +/** + * Creates a new file at given path. + * @param path The file path. + * @param content The file content. + * @returns The creation promise. + */ function createFile(path: string, content: string): Promise { console.log(`Creating file ${path}...`); return Deno.writeTextFile(path, content); } +/** + * Creates a new directory at given path. + * @param path The directory path. + * @returns The creation promise. + */ function createDir(path: string): Promise { console.log(`Creating directory ${path}...`); return Deno.mkdir(path); } +/** + * Create the index content. + * @param _name The module capitalized name. + * @returns The index content. + */ function getIndexContent(_name: string) { return ` import makeIndex from "$root/defaults/makeIndex.ts"; @@ -75,6 +95,11 @@ function getIndexContent(_name: string) { `; } +/** + * Create the partials index content. + * @param name The module capitalized name. + * @returns The partials index content. + */ function getPartialIndexContent(name: string) { return ` import { @@ -93,6 +118,11 @@ function getPartialIndexContent(name: string) { `; } +/** + * Create the props content. + * @param name The module capitalized name. + * @returns The props content. + */ function getPropsContent(name: string) { return ` import { AppProperties } from "$root/defaults/interfaces.ts"; @@ -111,6 +141,11 @@ function getPropsContent(name: string) { `; } +/** + * Create the API example content. + * @param _name The module capitalized name. + * @returns The API example content. + */ function getApiExampleContent(_name: string) { return ` import { Handlers } from "$fresh/server.ts"; diff --git a/toolbox/module/list.ts b/toolbox/module/list.ts index a348047..258e1a5 100644 --- a/toolbox/module/list.ts +++ b/toolbox/module/list.ts @@ -1,3 +1,6 @@ +/** + * List all modules of PolyMPR. + */ export async function listModules(): Promise { for await (const path of Deno.readDir("routes/(apps)")) { if (path.isDirectory) { From 5464077debc6b0fa0fb96ffbaf32a7c18060b662 Mon Sep 17 00:00:00 2001 From: Kevin FEDYNA Date: Wed, 22 Jan 2025 14:32:51 +0100 Subject: [PATCH 4/4] Added compile task --- deno.json | 1 + 1 file changed, 1 insertion(+) diff --git a/deno.json b/deno.json index 344187a..a7635bb 100644 --- a/deno.json +++ b/deno.json @@ -4,6 +4,7 @@ "check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx", "cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -", "pmpr": "deno run -A toolbox/cli.ts", + "compile": "deno compile -A --output \"/home/$USER/.deno/bin/pmpr\" toolbox/cli.ts", "manifest": "deno task cli manifest $(pwd)", "start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts", "build": "deno run -A --unstable-ffi dev.ts build",