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

### 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:
2026-01-23 20:07:16 -08:00
committed by Naomi Carrigan
parent 38e7f15d93
commit 6b5fa40599
59 changed files with 2249 additions and 48 deletions
+35
View File
@@ -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;
}
};
+210
View File
@@ -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();
+77
View File
@@ -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];
};
+89
View File
@@ -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;
}
};
+16
View File
@@ -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);
});
};