generated from nhcarrigan/template
feat: add multi-lang support and cohort scripts (#1)
CI / dependency-pin-check-typescript (push) Successful in 4s
CI / dependency-pin-check-python (push) Successful in 3s
CI / typescript (push) Successful in 9m38s
CI / python (push) Successful in 9m23s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / dependency-pin-check-typescript (push) Successful in 4s
CI / dependency-pin-check-python (push) Successful in 3s
CI / typescript (push) Successful in 9m38s
CI / python (push) Successful in 9m23s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #1 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { sleep } from "./sleep.js";
|
||||
|
||||
/**
|
||||
* Wraps the native fetch method in logic to back off
|
||||
* and retry on 429 errors.
|
||||
* @type {T} - The type of the response.
|
||||
* @param url - The URL to fetch.
|
||||
* @param options - The fetch options.
|
||||
* @returns The response, or null on error.
|
||||
*/
|
||||
export const backoffAndRetry
|
||||
= async<T>(url: string, options: RequestInit = {}): Promise<T | null> => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
await sleep(5000);
|
||||
return await backoffAndRetry(url, options);
|
||||
}
|
||||
throw new Error(`Request failed with status ${response.status.toString()}`);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors.
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @file Interactive script runner for ephemere project.
|
||||
* @copyright 2025 Naomi Carrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { select } from "@inquirer/prompts";
|
||||
|
||||
const currentFilename = fileURLToPath(import.meta.url);
|
||||
const currentDirectory = dirname(currentFilename);
|
||||
|
||||
interface ScriptOption {
|
||||
name: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const getTypeScriptCategories = (): Array<string> => {
|
||||
const sourcePath = join(currentDirectory, "..");
|
||||
const entries = readdirSync(sourcePath);
|
||||
|
||||
return entries.
|
||||
filter((entry) => {
|
||||
const fullPath = join(sourcePath, entry);
|
||||
const entryIsDirectory = statSync(fullPath).isDirectory();
|
||||
return entryIsDirectory && entry !== "utils" && entry !== "interfaces";
|
||||
}).
|
||||
sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
};
|
||||
|
||||
const getTypeScriptScripts = (category: string): Array<ScriptOption> => {
|
||||
const categoryPath = join(currentDirectory, "..", category);
|
||||
const scripts: Array<ScriptOption> = [];
|
||||
|
||||
const walkDirectory = (directory: string): void => {
|
||||
const entries = readdirSync(directory);
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(directory, entry);
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
walkDirectory(fullPath);
|
||||
} else if (entry.endsWith(".ts") && entry !== "index.ts") {
|
||||
const relativePath = relative(join(currentDirectory, ".."), fullPath);
|
||||
scripts.push({
|
||||
description: relativePath,
|
||||
name: entry.replace(".ts", ""),
|
||||
value: relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walkDirectory(categoryPath);
|
||||
return scripts.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
const getPythonCategories = (): Array<string> => {
|
||||
const pythonPath = join(currentDirectory, "../../../../python");
|
||||
const entries = readdirSync(pythonPath);
|
||||
|
||||
const categories = entries.
|
||||
filter((entry) => {
|
||||
const fullPath = join(pythonPath, entry);
|
||||
const entryIsDirectory = statSync(fullPath).isDirectory();
|
||||
const isNotHidden = !entry.startsWith(".");
|
||||
return entryIsDirectory && isNotHidden && entry !== "__pycache__";
|
||||
}).
|
||||
sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Also check for scripts in the root
|
||||
const hasRootScripts = entries.some((entry) => {
|
||||
return entry.endsWith(".py");
|
||||
});
|
||||
if (hasRootScripts) {
|
||||
categories.unshift("(root)");
|
||||
}
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
const getPythonScripts = (category: string): Array<ScriptOption> => {
|
||||
const pythonPath = join(currentDirectory, "../../../../python");
|
||||
const searchPath = category === "(root)"
|
||||
? pythonPath
|
||||
: join(pythonPath, category);
|
||||
|
||||
const scripts: Array<ScriptOption> = [];
|
||||
const entries = readdirSync(searchPath);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith(".py") && !entry.startsWith("__")) {
|
||||
const relativePath = category === "(root)"
|
||||
? entry
|
||||
: join(category, entry);
|
||||
scripts.push({
|
||||
description: relativePath,
|
||||
name: entry.replace(".py", ""),
|
||||
value: relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return scripts.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
const selectLanguage = async(): Promise<string> => {
|
||||
return await select({
|
||||
choices: [
|
||||
{
|
||||
description: "Run a TypeScript script",
|
||||
name: "TypeScript",
|
||||
value: "typescript",
|
||||
},
|
||||
{
|
||||
description: "Run a Python script",
|
||||
name: "Python",
|
||||
value: "python",
|
||||
},
|
||||
],
|
||||
message: "Which language would you like to run?",
|
||||
});
|
||||
};
|
||||
|
||||
const selectCategory = async(categories: Array<string>): Promise<string> => {
|
||||
return await select({
|
||||
choices: categories.map((cat) => {
|
||||
return {
|
||||
name: cat === "(root)"
|
||||
? "Root Directory"
|
||||
: cat.charAt(0).toUpperCase() + cat.slice(1),
|
||||
value: cat,
|
||||
};
|
||||
}),
|
||||
message: "Which category?",
|
||||
});
|
||||
};
|
||||
|
||||
const buildCommand = (language: string, script: string): string => {
|
||||
const environmentPath = join(currentDirectory, "../../../../../prod.env");
|
||||
const typescriptDirectory = join(currentDirectory, "../../../");
|
||||
const pythonDirectory = join(currentDirectory, "../../../../python");
|
||||
|
||||
return language === "typescript"
|
||||
? `cd ${typescriptDirectory} && op run --env-file=${environmentPath} -- pnpm exec tsx src/${script}`
|
||||
: `cd ${pythonDirectory} && op run --env-file=${environmentPath} -- uv run python ${script}`;
|
||||
};
|
||||
|
||||
const executeScript = (script: string, command: string): void => {
|
||||
console.log(`\n✨ Running: ${script}\n`);
|
||||
|
||||
try {
|
||||
execSync(command, {
|
||||
shell: "/bin/bash",
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
console.error("\n❌ Script execution failed!");
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const main = async(): Promise<void> => {
|
||||
console.log("🌸 Welcome to Ephemere Script Runner! 💖\n");
|
||||
|
||||
const language = await selectLanguage();
|
||||
|
||||
const categories = language === "typescript"
|
||||
? getTypeScriptCategories()
|
||||
: getPythonCategories();
|
||||
|
||||
if (categories.length === 0) {
|
||||
console.error(`No categories found for ${language}!`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const category = await selectCategory(categories);
|
||||
|
||||
const scripts = language === "typescript"
|
||||
? getTypeScriptScripts(category)
|
||||
: getPythonScripts(category);
|
||||
|
||||
if (scripts.length === 0) {
|
||||
console.error(`No scripts found in ${category}!`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const script = await select({
|
||||
choices: scripts,
|
||||
message: "Which script would you like to run?",
|
||||
});
|
||||
|
||||
const command = buildCommand(language, script);
|
||||
executeScript(script, command);
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { extname } from "node:path";
|
||||
|
||||
/**
|
||||
* MIME type mapping for file extensions.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- File extensions */
|
||||
/* eslint-disable stylistic/key-spacing -- Alignment for readability */
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".7z": "application/x-7z-compressed",
|
||||
".aac": "audio/aac",
|
||||
".avi": "video/x-msvideo",
|
||||
".bmp": "image/bmp",
|
||||
".css": "text/css",
|
||||
".csv": "text/csv",
|
||||
".doc": "application/msword",
|
||||
".docx":
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".flac": "audio/flac",
|
||||
".gif": "image/gif",
|
||||
".gz": "application/gzip",
|
||||
".htm": "text/html",
|
||||
".html": "text/html",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript",
|
||||
".json": "application/json",
|
||||
".md": "text/markdown",
|
||||
".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".ogg": "audio/ogg",
|
||||
".otf": "font/otf",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx":
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".svg": "image/svg+xml",
|
||||
".tar": "application/x-tar",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain",
|
||||
".wav": "audio/wav",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx":
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xml": "application/xml",
|
||||
".zip": "application/zip",
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- File extensions */
|
||||
/* eslint-enable stylistic/key-spacing -- Alignment for readability */
|
||||
|
||||
/**
|
||||
* Gets the MIME type for a file based on its extension.
|
||||
* @param filePath - The file name or path.
|
||||
* @returns The MIME type, or undefined if unknown.
|
||||
*/
|
||||
export const getMimeType = (filePath: string): string | undefined => {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
return mimeTypes[extension];
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches a paginated resource from a URL. Automatically handles pagination,
|
||||
* and returns the complete data as an array.
|
||||
* @type {Array<Record<string, unknown>>} - The type of data returned from the API endpoint. This should be an array of objects.
|
||||
* @param url - The URL to fetch.
|
||||
* @param limit - The number of items to fetch per page.
|
||||
* @param options - The standard fetch options object.
|
||||
* @returns The complete data as type T.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function, max-statements -- We're doing some complex logic here.
|
||||
export const paginatedFetch = async <T extends Array<Record<string, unknown>>>(
|
||||
url: string,
|
||||
limit: number,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> => {
|
||||
let page = 1;
|
||||
let offset = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors.
|
||||
const data: T = [] as unknown as T;
|
||||
|
||||
// First page
|
||||
const firstUrl = `${url}?limit=${limit.toString()}&page=${page.toString()}&offset=${offset.toString()}`;
|
||||
console.log(`Fetching page ${page.toString()} (offset ${offset.toString()}, limit ${limit.toString()})...`);
|
||||
let request = await fetch(firstUrl, options);
|
||||
|
||||
if (!request.ok) {
|
||||
throw new Error(`Failed to fetch ${firstUrl}: ${request.status.toString()} ${request.statusText}`);
|
||||
}
|
||||
|
||||
let response: T = await request.json();
|
||||
|
||||
// Check if response is actually an array
|
||||
if (!Array.isArray(response)) {
|
||||
console.error(
|
||||
"API response is not an array:",
|
||||
typeof response,
|
||||
Object.keys(response),
|
||||
);
|
||||
const errorMessage
|
||||
= `Expected array response but got ${typeof response}. `
|
||||
+ `Response keys: ${Object.keys(response).join(", ")}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
console.log(`Page ${page.toString()}: Received ${response.length.toString()} items`);
|
||||
data.push(...response);
|
||||
|
||||
/**
|
||||
* Continue paginating while we get items back.
|
||||
* Keep fetching until we get an empty array (0 items), which means we've reached the end.
|
||||
*/
|
||||
while (response.length > 0) {
|
||||
page = page + 1;
|
||||
offset = offset + limit;
|
||||
const pageUrl = `${url}?limit=${limit.toString()}&page=${page.toString()}&offset=${offset.toString()}`;
|
||||
console.log(`Fetching page ${page.toString()} (offset ${offset.toString()}, limit ${limit.toString()})...`);
|
||||
request = await fetch(pageUrl, options);
|
||||
|
||||
if (!request.ok) {
|
||||
console.error(`Failed to fetch page ${page.toString()}: ${request.status.toString()} ${request.statusText}`);
|
||||
break;
|
||||
}
|
||||
|
||||
response = await request.json();
|
||||
|
||||
if (!Array.isArray(response)) {
|
||||
console.error(`Page ${page.toString()} response is not an array:`, typeof response);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`Page ${page.toString()}: Received ${response.length.toString()} items`);
|
||||
|
||||
// If we get an empty array, we've reached the end
|
||||
if (response.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
data.push(...response);
|
||||
}
|
||||
|
||||
console.log(`Total items fetched: ${data.length.toString()}`);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Attempts to serialise a string as JSON. Includes error handling if the
|
||||
* string is not serialisable.
|
||||
* @param text -- The text to serialise.
|
||||
* @returns The serialised object, or null on error.
|
||||
*/
|
||||
export const serialiseJsonOrError
|
||||
= (text: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const object = JSON.parse(text);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- we know this is an object.
|
||||
return object;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Uses async promises to pause exection for the specified time.
|
||||
* @param ms - The number of milliseconds to pause for.
|
||||
* @returns The promise.
|
||||
*/
|
||||
export const sleep = async(ms: number): Promise<Promise<void>> => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user