UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

221 lines 11 kB
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; } };