shipmast
Version:
Automated License & Metadata applicators for Codebases.
293 lines (225 loc) • 8.66 kB
JavaScript
import fs from 'fs/promises';
import path from 'path'
import { glob } from 'glob';
import { createHash, randomUUID } from 'crypto';
/**
* Assigns path-related metadata.
*
* @param {object} obj - Object to mutate with path data.
* @param {string} rootPath - The base directory for relative path calculation.
* @param {string} filePath - The full path of the file.
*/
export function assignPathData(obj, rootPath, filePath) {
obj["$file_path_relative"] = path.relative(rootPath, filePath).replace(/\\/g, '/');
}
/**
* Assigns file statistics to an object.
*
* @param {object} obj - Object to mutate with file stats.
* @param {fs.Stats} stats - File statistics from fs.stat.
*/
export function assignStatsData(obj, stats) {
obj["$file_size"] = stats.size;
obj["$file_size_bytes"] = `${stats.size} bytes`;
}
/**
* Creates a structured data object with `pre` and `post` fields.
*
* @returns {object} An empty structured data object.
*/
export function makeData() {
return { pre: {}, post: {} };
}
/**
* Merges two data objects together.
*
* @param {object} dataA - First data object.
* @param {object} dataB - Second data object.
* @returns {object} - Merged data object.
*/
export function mergeData(dataA, dataB) {
return {
pre: { ...dataA.pre, ...dataB.pre },
post: { ...dataA.post, ...dataB.post },
};
}
function isValidUUID(uuid) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
uuid,
);
}
export function extractLicenseFields(matter) {
const fields = {};
const lines = matter.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
const match = trimmedLine.match(/(?:.*\s)?([a-zA-Z0-9_]+)\s*:\s*(.*)$/);
if (match) {
const key = match[1].trim();
const value = match[2].trim();
fields[key] = value;
}
}
return fields;
}
function hash(input) {
return createHash('sha256').update(input, 'utf8').digest('hex');
}
function templateLicense(license, data) {
for (const [key, value] of Object.entries(data)) {
license = license.replaceAll(`{{${key}}}`, value);
}
return license;
}
/**
* Extracts a shipmast block using `--˹ ... ˺--` as a signature.
* Ensures the block is properly removed and can be reinserted later.
*/
function extractDecorator(content) {
const blockCommentRegex = /\/\*˹[\s\S]*?˼\*\//g;
const shebangRegex = /^#![^\n]+\n/;
// Extract shebang first before processing anything else
let shebangMatch = content.match(shebangRegex);
let shebang = shebangMatch ? shebangMatch[0] : null;
// Remove the shebang from content before extracting the decorator
let newContent = shebang ? content.replace(shebangRegex, '').trimStart() : content;
// Extract block comment using the `/*˹ ... ˺*/` format
let matches = newContent.match(blockCommentRegex);
let decorator = matches ? matches[0] : null;
if (!decorator) {
console.error(`⚠️ Warning: No shipmast block found using /*˹ ... ˺*/.`);
return { decorator: null, content: newContent, shebang };
}
// Remove the shipmast block
newContent = newContent.replace(decorator, '').trimStart();
return { decorator, content: newContent, shebang };
}
/**
* Extracts the base directory from the given glob pattern.
*/
function getGlobBaseDir(globPattern) {
// Find the first part of the glob pattern before any wildcards
const parts = globPattern.split(/[*?\[\]]/); // Split at any glob pattern characters
return path.resolve(parts[0]); // Resolve the first segment as the base directory
}
export async function applyTemplateGlob(globPattern, template, options = {}) {
const { data = {}, dryRun = false } = options;
if (!globPattern || !template) {
throw new Error('❌ applyTemplateGlob requires a glob pattern and a template.');
}
// Determine where the search started
const baseDir = getGlobBaseDir(globPattern);
const files = await glob(globPattern, { nodir: true });
if (files.length === 0) {
console.warn(`No files matched pattern: ${globPattern}`);
return;
}
console.log(`Processing ${files.length} files...`);
for (const filePath of files) {
const content = await fs.readFile(filePath, 'utf8');
const fileStats = await fs.stat(filePath);
let fileData = makeData();
assignPathData(fileData.pre, baseDir, filePath);
assignStatsData(fileData.post, fileStats);
fileData = mergeData(fileData, { pre: data.pre || {}, post: data.post || {} });
const result = await applyTemplate(content, template, { data: fileData });
if (result.updated) {
if (dryRun) {
console.log(`🛑 [Dry Run] Would update: ${filePath}`);
} else {
await fs.writeFile(filePath, result.result, 'utf8');
console.log(`✅ Updated: ${filePath}`);
}
} else {
console.log(`No changes: ${filePath}`);
}
}
}
export async function applyTemplate(content, template, options={}) {
const {
decorator: previousLicense,
content: strippedContent,
shebang,
} = extractDecorator(content);
let DATA = {pre:{},post:{}};
if (previousLicense) {
Object.assign(DATA.pre, extractLicenseFields(previousLicense));
}
Object.assign(DATA.pre,options.data.pre)
Object.assign(DATA.post,options.data.post)
const date = new Date();
DATA.pre["$year_full"] = date.getFullYear();
if (!isValidUUID(DATA.pre.file_uuid)) {
DATA.post["$file_uuid"] = randomUUID();
} else {
DATA.post["$file_uuid"] = DATA.pre.file_uuid;
}
const oldDocHash = DATA.pre.file_hash;
const oldBlockHash = DATA.pre.mast_hash;
const newDocHash = hash(strippedContent);
let docUpdated = newDocHash !== oldDocHash;
template = templateLicense(template, DATA.pre);
const newBlockHash = hash(template);
DATA.post["$generated_on_iso"] = new Date().toISOString();
DATA.post["$file_hash"] = newDocHash;
DATA.post["$generated_by"] = 'shipmast on npm';
template = templateLicense(template, DATA.post);
let blockUpdated = newBlockHash !== oldBlockHash;
DATA.post["$mast_hash"] = newBlockHash;
const finalBlock = templateLicense(template, DATA.post);
let updated = docUpdated || blockUpdated;
if (updated) {
console.log('Result: Changes.');
} else {
console.log('Result: No Changes.');
}
let finalContent = `${shebang ? shebang : ''}/*˹${finalBlock}˼*/\n${strippedContent}`;
return { result: finalContent, updated };
}
const shipmastRegex = /\/\*˹[\s\S]*?˼\*\/\n?/g;
/**
* Removes the shipmast block from a given content string.
* Detects the `/-*˹ ... ˼*-/` signature and removes it.
*
* @param {string} content - The file content.
* @returns {string} - The cleaned content without the shipmast.
*/
export function remove(content)
{
return content.replace(shipmastRegex, '').trim();
}
/**
* Removes the shipmast block from all files matching a glob pattern.
*
* @param {string} globPattern - The glob pattern to match files.
* @param {object} options - Optional parameters.
* @param {boolean} options.dryRun - If true, will only log changes without modifying files.
*/
export async function removeGlob(globPattern, options = {}) {
const { dryRun = false } = options;
if (!globPattern) {
throw new Error('❌ removeGlob requires a glob pattern.');
}
const files = await glob(globPattern, { nodir: true });
if (files.length === 0) {
console.warn(`No files matched pattern: ${globPattern}`);
return;
}
console.log(`🔍 Removing shipmast from ${files.length} files...`);
for (const filePath of files) {
const content = await fs.readFile(filePath, 'utf8');
const cleanedContent = remove(content);
if (cleanedContent !== content) {
if (dryRun) {
console.log(`🛑 [Dry Run] Would remove shipmast from: ${filePath}`);
} else {
await fs.writeFile(filePath, cleanedContent, 'utf8');
console.log(`✅ Removed shipmast from: ${filePath}`);
}
} else {
console.log(`ℹ️ No shipmast found in: ${filePath}`);
}
}
}
export default {applyTemplate,applyTemplateGlob,remove,removeGlob,assignPathData,assignStatsData,mergeData,makeData}