@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
221 lines • 11 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { logger } from '../../utils/logger.js';
import { writeTextFile } from '../../utils/file.js';
import { normalizeOptionalString } from '../../utils/value.js';
const DEFAULT_SHARED_PACKAGE_NAME = '@limebooth/atlas-ai';
const GUIDE_SCOPE_VALUES = new Set(['package', 'cross-package', 'architecture', 'operations', 'migration']);
const SYMBOL_TEMPLATE_RELATIVE_PATH = ['templates', 'atlas-ai-symbol-doc.template.md'];
const GUIDE_TEMPLATE_RELATIVE_PATH = ['templates', 'atlas-ai-guide-doc.template.md'];
const normalizePathSeparators = value => value.split(path.sep).join('/');
const readTextFile = (filePath, dependencies = {}) => {
const {
fsImpl = fs
} = dependencies;
if (!fsImpl.existsSync(filePath)) {
return null;
}
return fsImpl.readFileSync(filePath, 'utf8');
};
const readPackageJson = (filePath, dependencies = {}) => {
const fileContent = readTextFile(filePath, dependencies);
if (fileContent === null) {
throw new Error(`Required package.json was not found at ${filePath}.`);
}
try {
return JSON.parse(fileContent);
} catch (error) {
throw new Error(`Invalid JSON in file: ${filePath}\nError: ${error.message}`);
}
};
const sanitizeFileName = value => value.replace(/[<>:"/\\|?*\x00-\x1F]/gu, '-').replace(/\s+/gu, '-').replace(/-+/gu, '-');
const slugifyGuideId = value => normalizeOptionalString(value)?.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '') ?? null;
const humanizeGuideTitle = value => {
const normalizedValue = normalizeOptionalString(value);
if (!normalizedValue) {
return null;
}
return normalizedValue.replace(/[-_]+/gu, ' ').split(/\s+/u).filter(Boolean).map(segment => segment.charAt(0).toUpperCase() + segment.slice(1)).join(' ');
};
const escapeTemplateValue = value => value.replaceAll("'", "''");
const createOwnerName = packageName => normalizeOptionalString(packageName)?.replace(/^@[^/]+\//u, '') ?? 'atlas-package';
const createAtlasVersion = value => value?.match(/\d+\.\d+\.\d+/u)?.[0] ?? '4.0.0';
const replaceTemplateTokens = (templateText, replacements) => Object.entries(replacements).reduce((resolvedText, [key, value]) => resolvedText.replaceAll(`{{${key}}}`, escapeTemplateValue(String(value))), templateText);
const resolveAuthoringPackage = (cwd, options = {}, dependencies = {}) => {
const {
fsImpl = fs,
pathImpl = path
} = dependencies;
const explicitPackageDirectory = normalizeOptionalString(options.packageDir);
if (explicitPackageDirectory) {
const packageDirectoryPath = pathImpl.resolve(cwd, explicitPackageDirectory);
const packageJsonPath = pathImpl.join(packageDirectoryPath, 'package.json');
const packageJson = readPackageJson(packageJsonPath, dependencies);
if (!packageJson.atlasAi) {
throw new Error(`Package directory ${packageDirectoryPath} does not contain an atlasAi configuration.`);
}
return {
packageDirectoryPath,
packageJson
};
}
let currentDirectoryPath = pathImpl.resolve(cwd);
while (true) {
const packageJsonPath = pathImpl.join(currentDirectoryPath, 'package.json');
if (fsImpl.existsSync(packageJsonPath)) {
const packageJson = readPackageJson(packageJsonPath, dependencies);
if (packageJson.atlasAi) {
return {
packageDirectoryPath: currentDirectoryPath,
packageJson
};
}
}
const parentDirectoryPath = pathImpl.dirname(currentDirectoryPath);
if (parentDirectoryPath === currentDirectoryPath) {
break;
}
currentDirectoryPath = parentDirectoryPath;
}
throw new Error(`No Atlas package with atlasAi configuration was found from ${cwd}. Run this command from a package directory or pass --package-dir.`);
};
const resolveSharedPackageRootPath = (packageDirectoryPath, sharedPackageName, dependencies = {}) => {
const {
fsImpl = fs,
pathImpl = path
} = dependencies;
const packageSegments = sharedPackageName.split('/');
const localPackageName = packageSegments[packageSegments.length - 1];
const attemptedManifestPaths = [];
let currentDirectoryPath = pathImpl.resolve(packageDirectoryPath);
while (true) {
const installedManifestPath = pathImpl.join(currentDirectoryPath, 'node_modules', ...packageSegments, 'atlas-ai-manifest.json');
const localWorkspaceManifestPath = pathImpl.join(currentDirectoryPath, 'packages', localPackageName, 'atlas-ai-manifest.json');
attemptedManifestPaths.push(installedManifestPath, localWorkspaceManifestPath);
if (fsImpl.existsSync(installedManifestPath)) {
return pathImpl.dirname(installedManifestPath);
}
if (fsImpl.existsSync(localWorkspaceManifestPath)) {
return pathImpl.dirname(localWorkspaceManifestPath);
}
const parentDirectoryPath = pathImpl.dirname(currentDirectoryPath);
if (parentDirectoryPath === currentDirectoryPath) {
break;
}
currentDirectoryPath = parentDirectoryPath;
}
throw new Error(`Atlas AI shared package was not found while scaffolding docs. Looked in: ${attemptedManifestPaths.join(', ')}.`);
};
const resolveTemplatePath = (type, sharedPackageRootPath, pathImpl = path) => pathImpl.join(sharedPackageRootPath, ...(type === 'guide' ? GUIDE_TEMPLATE_RELATIVE_PATH : SYMBOL_TEMPLATE_RELATIVE_PATH));
const createGuideScaffoldValues = (options, packageJson) => {
const guideId = slugifyGuideId(options.name);
if (!guideId) {
throw new Error('Guide scaffolding requires a non-empty --name value that can be converted to a guide id.');
}
const scope = normalizeOptionalString(options.scope) ?? 'package';
if (!GUIDE_SCOPE_VALUES.has(scope)) {
throw new Error(`Guide scope must be one of ${[...GUIDE_SCOPE_VALUES].join(', ')}.`);
}
const guideTitle = normalizeOptionalString(options.title) ?? humanizeGuideTitle(options.name) ?? humanizeGuideTitle(guideId);
const ownerName = createOwnerName(packageJson.name);
return {
fileName: `${sanitizeFileName(guideId)}.md`,
outputSubdirectory: ['ai-docs', 'guides'],
replacements: {
ATLAS_VERSION: createAtlasVersion(packageJson.version),
BODY_INTRO: 'Replace this body with the human-facing companion explanation after the semantic frontmatter is reviewed.',
ENTRYPOINT: normalizeOptionalString(options.entrypoint) ?? '.',
GUIDE_ID: guideId,
GUIDE_SCOPE: scope,
GUIDE_TITLE: guideTitle,
OWNER_NAME: ownerName,
PRIMARY_DECISION_SECTION: 'TODO: capture the main Atlas decision or workflow boundary this guide should teach.',
SUMMARY: normalizeOptionalString(options.summary) ?? `TODO: summarize the Atlas guidance covered by ${guideTitle}.`,
USE_WHEN_SECTION: `TODO: describe the concrete Atlas situations where ${guideTitle} should be consulted.`
},
title: guideTitle,
type: 'guide'
};
};
const createSymbolScaffoldValues = (options, packageJson) => {
const symbolName = normalizeOptionalString(options.name);
if (!symbolName) {
throw new Error('Symbol scaffolding requires a non-empty --name value.');
}
const ownerName = createOwnerName(packageJson.name);
return {
fileName: `${sanitizeFileName(symbolName)}.md`,
outputSubdirectory: ['ai-docs', 'symbols'],
replacements: {
ATLAS_VERSION: createAtlasVersion(packageJson.version),
BODY_INTRO: 'Replace this body with the human-facing companion explanation after the semantic frontmatter is reviewed.',
COMMON_MISTAKE_SECTION: `TODO: capture the most common misuse or confusing edge case for ${symbolName}.`,
ENTRYPOINT: normalizeOptionalString(options.entrypoint) ?? '.',
OWNER_NAME: ownerName,
PURPOSE: `TODO: describe the primary responsibility of ${symbolName} in ${packageJson.name}.`,
SUMMARY: normalizeOptionalString(options.summary) ?? `TODO: summarize when ${symbolName} should be used in Atlas.`,
SYMBOL_KIND: normalizeOptionalString(options.kind) ?? 'function',
SYMBOL_NAME: symbolName,
USE_WHEN_SECTION: `TODO: describe the concrete Atlas situations where ${symbolName} should be preferred.`
},
title: symbolName,
type: 'symbol'
};
};
export const createAiDocScaffold = (options = {}, dependencies = {}, cwd = process.cwd()) => {
const {
fsImpl = fs,
pathImpl = path
} = dependencies;
const type = normalizeOptionalString(options.type);
if (!type || !new Set(['symbol', 'guide']).has(type)) {
throw new Error('Atlas AI scaffolding requires --type with value symbol or guide.');
}
const packageContext = resolveAuthoringPackage(cwd, options, dependencies);
const sharedPackageRootPath = resolveSharedPackageRootPath(packageContext.packageDirectoryPath, normalizeOptionalString(options.sharedPackage) ?? DEFAULT_SHARED_PACKAGE_NAME, dependencies);
const scaffoldValues = type === 'guide' ? createGuideScaffoldValues(options, packageContext.packageJson) : createSymbolScaffoldValues(options, packageContext.packageJson);
const templatePath = resolveTemplatePath(type, sharedPackageRootPath, pathImpl);
const templateText = readTextFile(templatePath, dependencies);
if (templateText === null) {
throw new Error(`Atlas AI scaffold template was not found at ${templatePath}.`);
}
const outputPath = normalizeOptionalString(options.output) ? pathImpl.resolve(cwd, options.output) : pathImpl.join(packageContext.packageDirectoryPath, ...scaffoldValues.outputSubdirectory, scaffoldValues.fileName);
if (fsImpl.existsSync(outputPath) && options.force !== true && options.dryRun !== true) {
throw new Error(`Atlas AI ${type} doc already exists at ${outputPath}. Re-run with --force to overwrite it.`);
}
return {
content: replaceTemplateTokens(templateText, scaffoldValues.replacements).trimEnd(),
outputPath,
packageDirectoryPath: packageContext.packageDirectoryPath,
packageName: packageContext.packageJson.name,
relativeOutputPath: normalizePathSeparators(pathImpl.relative(cwd, outputPath)),
templatePath,
title: scaffoldValues.title,
type
};
};
export const runAiCreate = (options = {}, dependencies = {}, cwd = process.cwd()) => {
const loggerImpl = dependencies.loggerImpl ?? logger;
try {
const scaffold = createAiDocScaffold(options, dependencies, cwd);
if (options.dryRun === true) {
loggerImpl.info(`Prepared Atlas AI ${scaffold.type} scaffold preview for ${scaffold.relativeOutputPath}.`);
return {
...scaffold,
status: 'dry-run'
};
}
writeTextFile(scaffold.outputPath, scaffold.content, {
trailingNewline: true
}, dependencies);
loggerImpl.success(`Created Atlas AI ${scaffold.type} doc at ${scaffold.relativeOutputPath}.`);
loggerImpl.info('The scaffold starts as reviewStatus draft. Promote it only after package-owner review.');
return {
...scaffold,
status: 'created'
};
} catch (error) {
loggerImpl.error(error.message, false);
return null;
}
};