sb-mig
Version:
CLI to rule the world. (and handle stuff related to Storyblok CMS)
369 lines (368 loc) • 13.9 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadResourceContent = loadResourceContent;
exports.loadResources = loadResources;
exports.discoverComponents = discoverComponents;
exports.discoverDatasources = discoverDatasources;
exports.discoverRoles = discoverRoles;
const promises_1 = require("fs/promises");
const path_1 = require("path");
const url_1 = require("url");
/**
* Load the content of a resource file (.sb.js, .datasource.js, etc.)
* Uses dynamic import to load ES modules and CommonJS
*/
async function loadResourceContent(filePath) {
try {
// Use dynamic import which works for both ESM and CJS
const fileUrl = (0, url_1.pathToFileURL)(filePath).href;
const module = await Promise.resolve(`${fileUrl}`).then(s => __importStar(require(s)));
return module.default || module;
}
catch (error) {
throw new Error(`Failed to load ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Load multiple resources by file path
*/
async function loadResources(filePaths) {
const results = [];
for (const filePath of filePaths) {
const name = filePath
.split("/")
.pop()
?.replace(/\.sb\.(js|cjs|mjs|ts)$/, "")
.replace(/\.(datasource|roles)\.(js|cjs|ts)$/, "")
.replace(/\.sb\.(datasource|roles)\.(js|cjs|ts)$/, "") ||
"unknown";
try {
const data = await loadResourceContent(filePath);
results.push({ name, filePath, data });
}
catch (error) {
results.push({
name,
filePath,
data: null,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
/**
* Read componentsDirectories from storyblok.config.js if it exists
*/
async function readComponentDirectories(workingDir) {
const configFiles = [
"storyblok.config.js",
"storyblok.config.cjs",
"storyblok.config.mjs",
];
for (const configFile of configFiles) {
try {
const configPath = (0, path_1.join)(workingDir, configFile);
const configContent = await (0, promises_1.readFile)(configPath, "utf-8");
const match = configContent.match(/componentsDirectories\s*:\s*\[([\s\S]*?)\]/);
if (match && match[1]) {
const dirsMatch = match[1].match(/['"]([^'"]+)['"]/g);
if (dirsMatch) {
return dirsMatch.map((d) => d.replace(/['"]/g, ""));
}
}
break;
}
catch {
// Config file doesn't exist, continue
}
}
return ["src", "components", "storyblok"];
}
/**
* Check if a path is within the project directory
* Resolves symlinks and ensures we don't escape the project bounds
*/
async function isWithinProject(targetPath, projectRoot) {
try {
// Resolve both paths to handle symlinks
const resolvedTarget = await (0, promises_1.realpath)(targetPath);
const resolvedRoot = await (0, promises_1.realpath)(projectRoot);
// Use the platform separator so this works on Windows (`\`) and POSIX (`/`).
return (resolvedTarget.startsWith(resolvedRoot + path_1.sep) ||
resolvedTarget === resolvedRoot);
}
catch {
// If we can't resolve the path, assume it's not safe
return false;
}
}
/**
* Discover components in the working directory
* Prefers .ts for local files and .cjs for external (node_modules) files
* to avoid duplicates when both ESM and CJS versions exist
*
* Security: Stays within project bounds and doesn't follow symlinks outside
*/
async function discoverComponents(workingDir, options) {
const components = [];
// Priority order: .ts first (local), then .cjs (for node_modules)
// Skip .js and .mjs to avoid duplicates
const extensions = options?.extensions ?? [".sb.ts", ".sb.cjs"];
const includeExternal = options?.includeExternal ?? true;
const maxDepth = options?.maxDepth ?? 20;
// Resolve the project root for security checks
const projectRoot = (0, path_1.resolve)(workingDir);
const componentDirs = await readComponentDirectories(workingDir);
const scanDir = async (dir, isExternal, depth) => {
// Prevent excessive depth
if (depth > maxDepth) {
return;
}
// Security: Ensure we're still within project bounds
if (!(await isWithinProject(dir, projectRoot))) {
return;
}
try {
const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = (0, path_1.join)(dir, entry.name);
if (entry.isDirectory()) {
// Skip common non-source directories
if (entry.name === ".git" ||
entry.name === ".next" ||
entry.name === "dist" ||
entry.name === ".cache" ||
entry.name === "coverage") {
continue;
}
// Skip node_modules entirely if not including external
if (entry.name === "node_modules" && !includeExternal) {
continue;
}
const isNowExternal = isExternal || entry.name === "node_modules";
await scanDir(fullPath, isNowExternal, depth + 1);
}
else if (entry.isFile()) {
// Skip external files if not including them
if (isExternal && !includeExternal) {
continue;
}
for (const ext of extensions) {
if (entry.name.endsWith(ext) &&
!entry.name.startsWith("_")) {
const componentName = entry.name.replace(ext, "");
components.push({
name: componentName,
filePath: fullPath,
type: isExternal ? "external" : "local",
});
break;
}
}
}
}
}
catch {
// Directory doesn't exist or can't be read
}
};
for (const dir of componentDirs) {
const fullDir = (0, path_1.join)(workingDir, dir);
// Skip if the directory path includes node_modules and we're not including external
if (dir.includes("node_modules") && !includeExternal) {
continue;
}
try {
const dirStat = await (0, promises_1.stat)(fullDir);
if (dirStat.isDirectory()) {
await scanDir(fullDir, dir.includes("node_modules"), 0);
}
}
catch {
// Directory doesn't exist
}
}
// Also scan root
try {
const rootEntries = await (0, promises_1.readdir)(workingDir, { withFileTypes: true });
for (const entry of rootEntries) {
if (entry.isFile()) {
for (const ext of extensions) {
if (entry.name.endsWith(ext) &&
!entry.name.startsWith("_")) {
const componentName = entry.name.replace(ext, "");
if (!components.find((c) => c.name === componentName)) {
components.push({
name: componentName,
filePath: (0, path_1.join)(workingDir, entry.name),
type: "local",
});
}
break;
}
}
}
}
}
catch {
// Ignore
}
// Deduplicate: prefer .ts over .cjs for same component name
const seen = new Map();
for (const component of components) {
const existing = seen.get(component.name);
if (!existing) {
seen.set(component.name, component);
}
else {
// Prefer .ts files over .cjs
if (component.filePath.endsWith(".ts") &&
!existing.filePath.endsWith(".ts")) {
seen.set(component.name, component);
}
// Prefer local over external
else if (component.type === "local" &&
existing.type === "external") {
seen.set(component.name, component);
}
}
}
const deduplicated = Array.from(seen.values());
// Sort: local first, then by name
deduplicated.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "local" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
return deduplicated;
}
/**
* Discover datasources in the working directory
*/
async function discoverDatasources(workingDir) {
const datasources = [];
const extensions = [
".datasource.js",
".datasource.cjs",
".sb.datasource.js",
".sb.datasource.cjs",
];
const scanDir = async (dir) => {
try {
const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = (0, path_1.join)(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === ".git" ||
entry.name === ".next" ||
entry.name === "dist" ||
entry.name === "node_modules") {
continue;
}
await scanDir(fullPath);
}
else if (entry.isFile()) {
for (const ext of extensions) {
if (entry.name.endsWith(ext) &&
!entry.name.startsWith("_")) {
const name = entry.name
.replace(ext, "")
.replace(".sb", "");
datasources.push({
name,
filePath: fullPath,
type: "local",
});
break;
}
}
}
}
}
catch {
// Skip
}
};
await scanDir(workingDir);
datasources.sort((a, b) => a.name.localeCompare(b.name));
return datasources;
}
/**
* Discover roles in the working directory
*/
async function discoverRoles(workingDir) {
const roles = [];
const extensions = [".sb.roles.js", ".sb.roles.cjs", ".sb.roles.ts"];
const scanDir = async (dir) => {
try {
const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = (0, path_1.join)(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === ".git" ||
entry.name === ".next" ||
entry.name === "dist" ||
entry.name === "node_modules") {
continue;
}
await scanDir(fullPath);
}
else if (entry.isFile()) {
for (const ext of extensions) {
if (entry.name.endsWith(ext) &&
!entry.name.startsWith("_")) {
const name = entry.name.replace(ext, "");
roles.push({
name,
filePath: fullPath,
type: "local",
});
break;
}
}
}
}
}
catch {
// Skip
}
};
await scanDir(workingDir);
roles.sort((a, b) => a.name.localeCompare(b.name));
return roles;
}