chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:55 +08:00
commit 6db8fca185
437 changed files with 68762 additions and 0 deletions
@@ -0,0 +1,382 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/plugin-installer
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
/**
* Config editor
* =============
*
* Phase 3 PR 3.B (F7 + M-3). \`docmd add <plugin>\` and \`docmd remove
* <plugin>\` must mutate the project's config file in a way that
* works for every supported format:
*
* - \`docmd.config.json\` — JSON, parsed via \`JSON.parse\`.
* - \`docmd.config.js\` — CJS (\`module.exports = { ... }\`).
* - \`docmd.config.mjs\` — ESM (\`export default { ... }\` or
* \`export default defineConfig({...})\`).
* - \`docmd.config.cjs\` — CJS, same as \`.js\`.
* - \`docmd.config.ts\` — TS (\`export default defineConfig({...})\`).
*
* The previous implementation used a single regex
* (\`/module\\.exports\\s*=\\s*(?:defineConfig\\()?\\{...\}`) that
* matched only the CJS pattern. For \`export default\` (TS / MJS) the
* regex fell through silently, and the function returned \`false\`
* ("already configured") without actually editing the file. The user
* saw "Plugin successfully installed and activated" but the config
* was untouched (M-3). The same bug affected \`remove\` (F7).
*
* This module replaces the regex with a brace-balanced scanner that:
*
* 1. Strips comments first (line + block) so commented-out plugin
* entries are not treated as live config.
* 2. Detects the format by file extension.
* 3. For JSON: full parse + serialise (the only correct path).
* 4. For JS/TS/MJS/CJS: scans for the \`plugins:\` block using
* brace-balancing so multi-line nested objects are handled
* correctly. Inserts / removes the entry while preserving
* existing formatting (indent, trailing comma style, etc.).
*
* The scanner is intentionally dependency-free. The legacy regex
* approach is preserved as a fallback (and still used for format
* detection in \`detectConfigFormat\`); the new brace-balanced logic
* supersedes it for the add / remove paths.
*/
/**
* Strip line and block comments from a JS/TS config body. Used to
* avoid matching commented-out plugin entries.
*/
function stripComments(content: string): string {
return content
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
}
export type ConfigFormat = 'json' | 'js' | 'ts' | 'mjs' | 'cjs';
export function detectConfigFormat(configPath: string): ConfigFormat {
if (configPath.endsWith('.json')) return 'json';
if (configPath.endsWith('.ts')) return 'ts';
if (configPath.endsWith('.mjs')) return 'mjs';
if (configPath.endsWith('.cjs')) return 'cjs';
return 'js';
}
/**
* Escape a string for safe use inside a RegExp.
*/
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Find the \`plugins: { ... }\` block in a JS/TS/MJS/CJS config body.
* Uses brace-balancing to correctly handle nested objects.
*
* Returns the \`{ ... }\` extent (inclusive of braces) or null if no
* plugins block exists.
*/
function findPluginsBlock(content: string): { start: number; end: number } | null {
// Look for `plugins:` (possibly with whitespace, possibly quoted,
// possibly with comments) followed by `{`. The `g` flag is important
// because some configs have `theme.plugins` etc.
// Strip comments first to avoid false matches.
const stripped = stripComments(content);
const re = /\bplugins\s*:\s*\{/g;
let m: RegExpExecArray | null;
while ((m = re.exec(stripped)) !== null) {
const openIdx = m.index + m[0].length - 1; // index of `{`
let depth = 0;
for (let i = openIdx; i < stripped.length; i++) {
const ch = stripped[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return { start: openIdx, end: i };
} else if (ch === '"' || ch === "'" || ch === '`') {
// Skip string literals so braces inside strings do not
// affect the depth count.
const quote = ch;
i++;
while (i < stripped.length && stripped[i] !== quote) {
if (stripped[i] === '\\') i++; // skip escaped char
i++;
}
}
}
}
return null;
}
/**
* Detect the indentation of a block's content. Returns the leading
* whitespace of the first non-empty line inside the block.
*/
function detectIndent(inner: string): string {
const lines = inner.split('\n');
for (const line of lines) {
if (line.trim() === '') continue;
const m = line.match(/^[\t ]*/);
return m ? m[0] : ' ';
}
return ' ';
}
/**
* Add a plugin entry to the \`plugins: { ... }\` block. Returns the
* new content, or the original content if the entry is already
* present (in which case the caller should report "already
* configured").
*
* @param content The full config file body.
* @param configKey The plugin key (e.g. \`search\`, \`mermaid\`).
* @param valueText The value to assign, as a JS source string
* (e.g. \`{}\` or \`{ theme: 'dark' }\`).
*/
export function addPluginToPluginsBlock(
content: string,
configKey: string,
valueText: string
): { newContent: string; changed: boolean } {
// Comment-aware "already configured" check.
const stripped = stripComments(content);
const alreadyRegex = new RegExp(
`(?:^|[\\s,\\{])(['"\`]?)${escapeRe(configKey)}\\1\\s*:`,
'm'
);
if (alreadyRegex.test(stripped)) {
return { newContent: content, changed: false };
}
const block = findPluginsBlock(content);
if (block) {
const inner = content.slice(block.start + 1, block.end);
const indent = detectIndent(inner) || ' ';
const entryIndent = indent + ' ';
const newEntry = `${entryIndent}'${configKey}': ${valueText}`;
// Insert into the existing block. Honour trailing-comma style:
// { } -> { 'search': {} }
// { foo } -> { foo, 'search': {} }
// { foo, } -> { foo, 'search': {} }
const trimmed = inner.replace(/\s+$/, ''); // strip trailing whitespace
if (trimmed.trim() === '') {
const newInner = `\n${newEntry}\n${indent}`;
return {
newContent:
content.slice(0, block.start + 1) + newInner + content.slice(block.end),
changed: true
};
}
const endsWithComma = trimmed.trimEnd().endsWith(',');
const sep = endsWithComma ? '\n' : ',\n';
const newInner = trimmed + sep + newEntry;
return {
newContent:
content.slice(0, block.start + 1) + newInner + content.slice(block.end),
changed: true
};
}
// No `plugins:` block yet — find the top-level object and add one.
// The top-level object can be:
// - JSON: { ... }
// - CJS: module.exports = { ... }
// - CJS + defineConfig: module.exports = defineConfig({ ... });
// - ESM: export default { ... }
// - ESM + defineConfig: export default defineConfig({ ... });
// - TS + defineConfig: export default defineConfig<UserConfig>({ ... });
const added = addPluginsBlockToTopLevel(content, configKey, valueText);
return { newContent: added, changed: added !== content };
}
/**
* Find the top-level object literal in a JS/TS config and add a
* \`plugins: { 'key': value }\` block to it. Returns the original
* content unchanged if no top-level object can be found.
*/
function addPluginsBlockToTopLevel(
content: string,
configKey: string,
valueText: string
): string {
// Strip comments so the search isn't confused by commented-out
// export statements.
const stripped = stripComments(content);
// Look for the FIRST opening `{` after an `export default` or
// `module.exports =` keyword. We want the top-level object, not a
// nested one.
const exportMatch = stripped.match(
/(?:export\s+default\s+(?:defineConfig(?:\s*<[^>]*>)?\s*)?|module\.exports\s*=\s*(?:defineConfig\s*)?)\s*\{/
);
if (!exportMatch) return content;
const openIdx = (exportMatch.index ?? 0) + exportMatch[0].length - 1;
// Brace-balance to find the matching `}`.
let depth = 0;
let endIdx = -1;
for (let i = openIdx; i < stripped.length; i++) {
const ch = stripped[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
endIdx = i;
break;
}
} else if (ch === '"' || ch === "'" || ch === '`') {
const quote = ch;
i++;
while (i < stripped.length && stripped[i] !== quote) {
if (stripped[i] === '\\') i++;
i++;
}
}
}
if (endIdx === -1) return content;
// Find the LAST non-whitespace, non-`}` character before `endIdx`
// to detect if a trailing comma is already present.
const inner = content.slice(openIdx + 1, endIdx);
const trimmedInner = inner.replace(/\s+$/, '');
const endsWithComma = trimmedInner.trimEnd().endsWith(',');
// Match the indentation of the first sibling key in the object.
const indent = detectIndent(inner) || ' ';
const entryIndent = indent + ' ';
// Honour the existing trailing-comma style:
// { foo } -> { foo, plugins: { ... } }
// { foo, } -> { foo, plugins: { ... } }
// { } -> { plugins: { ... } }
// The block is appended directly after the trimmed inner (which may
// already end in a newline); we do NOT add another newline here.
const sep = endsWithComma || trimmedInner.trim() === '' ? '' : ',\n';
const pluginsBlock =
sep +
`${indent}plugins: {\n` +
`${entryIndent}'${configKey}': ${valueText}\n` +
`${indent}}\n`;
const newInner = trimmedInner + pluginsBlock;
return (
content.slice(0, openIdx + 1) +
newInner +
content.slice(endIdx)
);
}
/**
* Remove a plugin entry from the \`plugins: { ... }\` block. Returns
* the new content plus a `changed` flag. If the entry is not
* present, `changed` is false and the original content is returned.
*/
export function removePluginFromPluginsBlock(
content: string,
configKey: string
): { newContent: string; changed: boolean } {
// Comment-aware "is present" check.
const stripped = stripComments(content);
const presentRegex = new RegExp(
`(?:^|[\\s,\\{])(['"\`]?)${escapeRe(configKey)}\\1\\s*:`,
'm'
);
if (!presentRegex.test(stripped)) {
return { newContent: content, changed: false };
}
const block = findPluginsBlock(content);
if (!block) return { newContent: content, changed: false };
const inner = content.slice(block.start + 1, block.end);
// Match the entry. Tolerate:
// 'key': { ... }
// "key": { ... }
// `key`: { ... }
// key: { ... } (unquoted)
// The value is balanced braces (no nesting for default configs).
// Allow trailing comma and surrounding whitespace.
//
// The replacement is empty (NOT '\n') so that an empty `plugins: {}`
// block does not become `plugins: {\n}`. The caller (the installer)
// checks `changed` and only reports success when an actual edit was
// made; the visual rendering of the block is preserved.
const re = new RegExp(
`\\n?\\s*(['"\`]?)${escapeRe(configKey)}\\1\\s*:\\s*\\{[^{}]*\\}[,]?\\s*\\n?`,
'g'
);
const newInner = inner.replace(re, '');
if (newInner === inner) {
return { newContent: content, changed: false };
}
return {
newContent:
content.slice(0, block.start + 1) +
newInner +
content.slice(block.end),
changed: true
};
}
// ─────────────────────────────────────────────────────────────────────
// JSON path — uses JSON.parse / JSON.stringify for full safety.
// ─────────────────────────────────────────────────────────────────────
export function addPluginToJsonConfig(
content: string,
configKey: string,
valueText: string
): { newContent: string; changed: boolean } {
let config: any;
try {
config = JSON.parse(content);
} catch (e: any) {
throw new Error(`Could not parse config as JSON: ${e.message}`);
}
config.plugins = config.plugins || {};
if (configKey in config.plugins) {
return { newContent: content, changed: false };
}
// Parse the value text. The installer passes JS-ish text like
// `{}` or `{ theme: 'dark' }`; we normalise it to valid JSON by
// quoting unquoted keys and replacing single quotes with double.
const normalised = valueText
.replace(/'/g, '"')
.replace(/([{,]\s*)([A-Za-z_][\w-]*)(\s*:)/g, '$1"$2"$3');
let value: any;
try {
value = JSON.parse(normalised);
} catch {
// Fall back to a plain object — the value will serialise as `{}`.
value = {};
}
config.plugins[configKey] = value;
return { newContent: JSON.stringify(config, null, 2) + '\n', changed: true };
}
export function removePluginFromJsonConfig(
content: string,
configKey: string
): { newContent: string; changed: boolean } {
let config: any;
try {
config = JSON.parse(content);
} catch (e: any) {
throw new Error(`Could not parse config as JSON: ${e.message}`);
}
if (!config.plugins || !(configKey in config.plugins)) {
return { newContent: content, changed: false };
}
delete config.plugins[configKey];
return { newContent: JSON.stringify(config, null, 2) + '\n', changed: true };
}
+559
View File
@@ -0,0 +1,559 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/core (and ecosystem)
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
import fs from 'fs';
import path from 'path';
import { execSync, execFileSync } from 'child_process';
import { createRequire } from 'module';
import {
detectConfigFormat,
addPluginToJsonConfig,
addPluginToPluginsBlock,
removePluginFromJsonConfig,
removePluginFromPluginsBlock
} from './config-editor.js';
import { isCorePlugin } from '@docmd/api';
const require = createRequire(import.meta.url);
// Plugin registry resolution. As of 0.8.9 the canonical registry is
// generated into @docmd/api/registry/plugins.generated.json by
// scripts/build-plugin-registry.mjs (single source of truth across the
// docmd monorepo). The bundled registry/plugins.json in this package is
// retained as a fallback for users who install @docmd/plugin-installer
// without @docmd/api, but the generated file is preferred.
//
// The bundled registry/plugins.json is DEPRECATED and will be removed in
// 0.9.0. The auto-install workflow and `docmd add <plugin>` both fall
// through to the new generated registry transparently.
let pluginsRegistry: any = {};
try {
const generatedPath = require.resolve('@docmd/api/registry/plugins.generated.json', {
paths: [process.cwd(), require('path').dirname(require.resolve('@docmd/api/package.json', { paths: [process.cwd()] }))]
});
pluginsRegistry = require(generatedPath);
} catch {
// Fallback: bundled registry (DEPRECATED, removed in 0.9.0).
try {
pluginsRegistry = require('../registry/plugins.json');
} catch {
pluginsRegistry = {};
}
}
/**
*
* @param err The thrown error from execFileSync.
* @param cmdExe The package-manager binary that was being spawned.
* @param action Human verb describing what was happening, e.g.
* "install" or "remove". Used in the suggestion copy.
* @param opts.verbose If true, return the raw `err.message` so power
* users can still see the full Node error.
*/
function formatSpawnError(err: any, cmdExe: string, action: string, opts: { verbose?: boolean } = {}): string {
const isMissingBinary = err && err.code === 'ENOENT' &&
typeof err.syscall === 'string' && err.syscall.startsWith('spawn');
if (isMissingBinary) {
const binary = err.path || cmdExe;
// Special case: if the failing binary is a package manager but Node itself
// is reachable, the issue is likely that docmd was launched through npx
// and the spawned child process can't see the parent's PATH. Suggest
// installing the plugin via the host package manager directly.
const nodeReachable = (() => {
try { require('child_process').execFileSync(process.execPath, ['--version'], { stdio: 'pipe' }); return true; }
catch { return false; }
})();
const isPkgManager = /^(npm|pnpm|yarn|bun)(\.cmd)?$/i.test(path.basename(binary));
if (nodeReachable && isPkgManager) {
return [
`Could not spawn ${binary} — it was not found on PATH when running through npx.`,
``,
`This is a common issue when @docmd/core itself was launched via npx on`,
`Windows or in restricted shells. Install the plugin directly using your`,
`package manager, then run the build again:`,
``,
` npm install <package-name>`,
``,
`If you still see this after a direct install, run with --verbose for the`,
`full spawn error.`,
].join('\n');
}
return [
`The package manager '${binary}' was not found on your system PATH.`,
``,
`To fix it:`,
` 1. Install ${binary} (e.g. \`npm install -g ${binary}\`, or use a`,
` Node version manager like nvm / volta / fnm)`,
` 2. Verify it's reachable: \`${binary} --version\` should print a version`,
` 3. Retry: \`npx @docmd/core ${action} <name>\``,
``,
`If you just installed ${binary}, restart your terminal so the updated`,
`PATH takes effect.`,
].join('\n');
}
if (opts.verbose && err && err.message) return err.message;
return 'Run with --verbose for detailed logs.';
}
/**
* Resolves the absolute path to a package-manager binary, falling back
* to PATH lookup if not found next to Node.
*
* On Windows, `npm`/`pnpm`/`yarn` are `.cmd` shims that may not be on
* PATH when `@docmd/core` is itself launched through `npx`. Since all
* major package managers ship their CLI scripts in Node's install
* directory (e.g. `node_modules/npm/bin/npm-cli.js`), resolving from
* `process.execPath` works reliably across all platforms and invocation
* methods (including `npx`).
*/
function resolvePackageManagerBin(name: string): string {
const nodeDir = path.dirname(process.execPath);
// Candidate scripts to look for, in priority order.
const candidates: Record<string, string[]> = {
npm: ['npm-cli.js', `npm${process.platform === 'win32' ? '.cmd' : ''}`],
pnpm: ['pnpm.js', `pnpm${process.platform === 'win32' ? '.cmd' : ''}`],
yarn: ['yarn.js', `yarn${process.platform === 'win32' ? '.cmd' : ''}`],
bun: ['bun.js', `bun${process.platform === 'win32' ? '.cmd' : ''}`],
};
const files = candidates[name] || [name];
// 1. Look next to the running Node binary (npm ships here, pnpm global does too).
for (const file of files) {
const candidate = path.join(nodeDir, file);
if (fs.existsSync(candidate)) return candidate;
}
// 2. Look in Node's lib/node_modules (npm/pnpm install here on most setups).
const libModules = path.join(nodeDir, 'lib', 'node_modules');
for (const file of files) {
const candidate = path.join(libModules, name, file === `${name}.cmd` ? 'bin' : 'bin', file);
if (fs.existsSync(candidate)) return candidate;
}
// 3. Last resort: return the bare name and let PATH resolve it.
return files[files.length - 1];
}
/**
* Detects the package manager used in the current project by looking for lockfiles upwards.
* Defaults to 'npm' if no lockfile is found.
*/
function getPackageManager(cwd) {
let dir = cwd;
while (dir !== path.parse(dir).root) {
if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
if (fs.existsSync(path.join(dir, 'bun.lockb'))) return 'bun';
if (fs.existsSync(path.join(dir, 'package-lock.json'))) return 'npm';
dir = path.dirname(dir);
}
return 'npm';
}
/**
* Resolves the project's config file path. Phase 3 PR 3.B (M-3):
* checks all five supported formats in preference order (JSON > JS >
* MJS > CJS > TS) and falls back to JSON when no config exists.
*
* The previous implementation only looked at `.json` and `.js`, so a
* project with `docmd.config.ts` would silently scaffold a new
* `docmd.config.json` instead of editing the existing TS file.
*/
function resolveConfigPath(cwd) {
const candidates = [
'docmd.config.json',
'docmd.config.js',
'docmd.config.mjs',
'docmd.config.cjs',
'docmd.config.ts'
];
for (const name of candidates) {
const p = path.join(cwd, name);
if (fs.existsSync(p)) return p;
}
return path.join(cwd, 'docmd.config.json');
}
function detectConfigFormatLegacy(configPath) {
return configPath.endsWith('.json') ? 'json' : 'js';
}
/**
* Resolves plugin metadata from the registry, or builds a fallback object.
*/
function resolvePluginMeta(name) {
if (pluginsRegistry[name]) {
return pluginsRegistry[name];
}
throw new Error(`Plugin "${name}" not found in the official registry. For custom plugins, please install via your package manager and configure manually.`);
}
/**
* Reads config and safely injects the plugin to the `plugins` object.
*
* Phase 3 PR 3.B (F7 + M-3): replaced the legacy regex-based injector
* (which only matched `module.exports = { ... }` and silently no-op'd
* for `export default defineConfig({...})` configs) with the
* brace-balanced scanner in `./config-editor.js`. JSON configs are
* handled via `JSON.parse` for full safety; JS / TS / MJS / CJS
* configs use a scanner that finds the `plugins:` block by
* brace-balancing and adds the entry while preserving the existing
* indentation and trailing-comma style.
*/
function injectPluginToConfig(configPath, meta) {
const configKey = meta.configKey;
const valueText = meta.defaultConfig || '{}';
let content = '';
if (fs.existsSync(configPath)) {
content = fs.readFileSync(configPath, 'utf8');
} else {
// Scaffold a minimal config in the matching format.
const fmt = detectConfigFormat(configPath);
if (fmt === 'json') {
content = '{\n "plugins": {}\n}\n';
} else if (fmt === 'mjs' || fmt === 'ts') {
content = "export default {\n plugins: {}\n};\n";
} else {
content = "module.exports = {\n plugins: {}\n};\n";
}
}
const fmt = detectConfigFormat(configPath);
let result: { newContent: string; changed: boolean };
if (fmt === 'json') {
result = addPluginToJsonConfig(content, configKey, valueText);
} else {
result = addPluginToPluginsBlock(content, configKey, valueText);
}
if (!result.changed) {
return false; // Already present
}
fs.writeFileSync(configPath, result.newContent, 'utf8');
return true;
}
/**
* Removes the plugin from the config file. Phase 3 PR 3.B (F7) fix:
* the legacy regex only matched CJS-style configs; the brace-balanced
* scanner handles all five supported formats.
*/
function removePluginFromConfig(configPath, meta) {
if (!fs.existsSync(configPath)) return false;
const content = fs.readFileSync(configPath, 'utf8');
const configKey = meta.configKey;
const fmt = detectConfigFormat(configPath);
let result: { newContent: string; changed: boolean };
if (fmt === 'json') {
result = removePluginFromJsonConfig(content, configKey);
} else {
result = removePluginFromPluginsBlock(content, configKey);
}
if (!result.changed) {
return false; // Not present
}
fs.writeFileSync(configPath, result.newContent, 'utf8');
return true;
}
/**
* Sets `theme.template` in the config. Replaces any existing template value.
* Returns true if a change was made. Handles both JSON and JS config formats.
* Templates do NOT stack — re-running with a different template overwrites.
*/
function injectTemplateToConfig(configPath, meta) {
const format = detectConfigFormat(configPath);
const templateName = meta.templateName || meta.configKey;
let content = '';
if (fs.existsSync(configPath)) {
content = fs.readFileSync(configPath, 'utf8');
} else {
content = format === 'json'
? '{\n "theme": {}\n}\n'
: 'module.exports = {\n theme: {}\n};\n';
}
if (format === 'json') {
let config;
try { config = JSON.parse(content); }
catch (err) {
TUI.warn(`Could not parse ${configPath} as JSON. Skipping template injection.`);
return false;
}
config.theme = config.theme || {};
if (config.theme.template === templateName) return false;
config.theme.template = templateName;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
return true;
}
// JS config (regex-based, matching the existing plugin injector)
const escaped = templateName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`template\\s*:\\s*['"\`]${escaped}['"\`]`).test(content)) return false;
const themeRegex = /theme\s*:\s*\{([\s\S]*?)\}/;
const themeMatch = content.match(themeRegex);
if (themeMatch) {
const inner = themeMatch[1];
let newInner;
if (/template\s*:/.test(inner)) {
// Replace existing `template: "..."` value
newInner = inner.replace(/template\s*:\s*['"`][^'"`]*['"`]/, `template: "${templateName}"`);
} else {
const trimmed = inner.trim();
if (trimmed === '') {
newInner = `\n template: "${templateName}"\n `;
} else {
// Strip trailing whitespace + optional comma, then add comma + template
const stripped = inner.replace(/[\s,]+$/, '');
newInner = `${stripped},\n template: "${templateName}"\n `;
}
}
content = content.replace(themeMatch[0], `theme: {${newInner}}`);
} else {
// No `theme: { ... }` yet — create one in module.exports
const moduleExportsRegex = /module\.exports\s*=\s*(?:defineConfig\()?\{([\s\S]*?)\}(?:\))?;?/g;
let matchE, lastMatch;
while ((matchE = moduleExportsRegex.exec(content)) !== null) lastMatch = matchE;
if (lastMatch) {
const closingBraceIndex = lastMatch.index + lastMatch[0].lastIndexOf('}');
const prefixRaw = content.substring(0, closingBraceIndex);
const suffix = content.substring(closingBraceIndex);
// Strip trailing whitespace from prefix so the separator lands cleanly after the last value
const prefix = prefixRaw.replace(/\s+$/, '');
const lastChar = prefix.slice(-1);
const separator = (lastChar === ',' || lastChar === '{') ? '\n ' : ',\n ';
const insert = `${separator}theme: {\n template: "${templateName}"\n }\n`;
content = prefix + insert + suffix;
} else {
TUI.warn(`Could not automatically inject template into ${configPath}. Please set theme.template = "${templateName}" manually.`);
return false;
}
}
fs.writeFileSync(configPath, content, 'utf8');
return true;
}
/**
* Clears `theme.template` from the config (reverts to default).
* Returns true if a change was made.
*/
function removeTemplateFromConfig(configPath) {
if (!fs.existsSync(configPath)) return false;
const format = detectConfigFormat(configPath);
const content = fs.readFileSync(configPath, 'utf8');
if (format === 'json') {
let config;
try { config = JSON.parse(content); }
catch { return false; }
if (!config.theme || !('template' in config.theme)) return false;
delete config.theme.template;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
return true;
}
// JS config
if (!/template\s*:/.test(content)) return false;
const newContent = content.replace(/\s*template\s*:\s*['"`][^'"`]*['"`]\s*,?\s*/, '');
if (content === newContent) return false;
fs.writeFileSync(configPath, newContent, 'utf8');
return true;
}
import { TUI } from '@docmd/api';
async function installPlugin(pluginInput: string, opts: { verbose?: boolean } = {}) {
const cwd = process.cwd();
const pkgManager = getPackageManager(cwd);
let meta;
try {
meta = resolvePluginMeta(pluginInput);
} catch (err: any) {
TUI.error('Installation Aborted', err.message);
return;
}
// Core plugins (search, seo, sitemap, analytics, llms, mermaid, git,
// openapi, okf) ship with @docmd/core — they're already installed
// and auto-loaded. `docmd add` for a core plugin would be a no-op at
// best and a config corruption at worst (it would inject the entry
// even though the package is a workspace dep already, leading to a
// duplicate install on user systems). The list lives in @docmd/api
// as CORE_PLUGINS so it stays in sync with hooks.ts.
if (!meta.kind && isCorePlugin(meta.configKey)) {
TUI.error(
'Installation Aborted (Plugin Already Present)',
`Plugin "${meta.configKey}" is a core plugin that ships with @docmd/core. ` +
`You can customise plugin behaviour via "plugins.${meta.configKey}" in your config.`
);
process.exit(1);
}
const packageName = meta.package;
const isTemplate = meta.kind === 'template';
TUI.section(isTemplate ? 'Template Installation' : 'Plugin Installation');
TUI.step(`Installing ${packageName} via ${pkgManager}`, 'WAIT');
let cmdExe = '';
let cmdArgs: string[] = [];
if (pkgManager === 'npm') { cmdExe = resolvePackageManagerBin('npm'); cmdArgs = ['install', packageName]; }
else if (pkgManager === 'yarn') { cmdExe = resolvePackageManagerBin('yarn'); cmdArgs = ['add', packageName]; }
else if (pkgManager === 'pnpm') { cmdExe = resolvePackageManagerBin('pnpm'); cmdArgs = ['add', packageName]; }
else if (pkgManager === 'bun') { cmdExe = resolvePackageManagerBin('bun'); cmdArgs = ['add', packageName]; }
if (pkgManager === 'npm' && !fs.existsSync(path.join(cwd, 'package.json'))) {
cmdArgs.push('--no-save');
}
try {
const stdioMode = opts.verbose ? 'inherit' : 'pipe';
execFileSync(cmdExe, cmdArgs, { stdio: stdioMode, cwd });
TUI.step(packageName, 'DONE');
const configPath = resolveConfigPath(cwd);
TUI.divider('Configuration');
let injected;
if (isTemplate) {
TUI.step(`Setting theme.template to "${meta.configKey}"`, 'WAIT', TUI.blue);
injected = injectTemplateToConfig(configPath, meta);
} else {
TUI.step(`Activating ${meta.configKey}`, 'WAIT', TUI.blue);
injected = injectPluginToConfig(configPath, meta);
}
if (injected) {
TUI.step(isTemplate ? 'Template activated' : 'Activation completed', 'DONE', TUI.blue);
} else {
TUI.step(isTemplate ? 'Template already configured' : 'Plugin already configured', 'SKIP', TUI.blue);
}
TUI.footer();
// M-14: the final success message must reflect what actually happened.
// When the plugin was already configured, the config was untouched —
// calling that "successfully installed" is misleading. Branch on the
// `injected` flag from the config editor and emit one of three
// messages: new install, already configured, or core-plugin (which
// is handled above with a hard error so we never reach this line
// for that case).
if (injected) {
TUI.success(isTemplate ? 'Template successfully installed and activated.' : 'Plugin successfully installed and activated.');
} else {
TUI.info(isTemplate ? 'Template was already configured. Nothing changed.' : 'Plugin was already configured. Nothing changed.');
}
} catch (err: any) {
TUI.step(packageName, 'FAIL');
TUI.footer();
TUI.error(`Could not install ${packageName}`, formatSpawnError(err, cmdExe, 'add', opts));
}
}
async function removePlugin(pluginInput: string, opts: { verbose?: boolean } = {}) {
const cwd = process.cwd();
const pkgManager = getPackageManager(cwd);
let meta;
try {
meta = resolvePluginMeta(pluginInput);
} catch (err: any) {
TUI.error('Removal Aborted', err.message);
// Phase 3 PR 3.A (F6): exit 1 so CI pipelines can gate on the
// documented "Removal Aborted" failure path. Previously this
// `return` left the process at exit code 0, silently passing a
// failed removal.
process.exit(1);
}
// Same gate as installPlugin: core plugins can't be removed because
// they're a workspace dep of @docmd/core, not user-installed.
if (!meta.kind && isCorePlugin(meta.configKey)) {
TUI.error(
'Removal Aborted (Core Plugin)',
`Plugin "${meta.configKey}" is a core plugin that ships with @docmd/core. ` +
`It cannot be removed — opt out instead via "plugins.${meta.configKey}: false" in your config.`
);
process.exit(1);
}
const packageName = meta.package;
const isTemplate = meta.kind === 'template';
TUI.section(isTemplate ? 'Template Removal' : 'Plugin Removal');
TUI.step(`Uninstalling ${packageName} via ${pkgManager}`, 'WAIT');
let cmdExe = '';
let cmdArgs: string[] = [];
if (pkgManager === 'npm') { cmdExe = 'npm'; cmdArgs = ['uninstall', packageName]; }
else if (pkgManager === 'yarn') { cmdExe = 'yarn'; cmdArgs = ['remove', packageName]; }
else if (pkgManager === 'pnpm') { cmdExe = 'pnpm'; cmdArgs = ['remove', packageName]; }
else if (pkgManager === 'bun') { cmdExe = 'bun'; cmdArgs = ['remove', packageName]; }
try {
const stdioMode = opts.verbose ? 'inherit' : 'pipe';
execFileSync(cmdExe, cmdArgs, { stdio: stdioMode, cwd });
TUI.step(packageName, 'DONE');
const configPath = resolveConfigPath(cwd);
TUI.divider('Configuration');
let removed;
if (isTemplate) {
TUI.step('Clearing theme.template', 'WAIT', TUI.blue);
removed = removeTemplateFromConfig(configPath);
} else {
TUI.step(`Removing ${meta.configKey}`, 'WAIT', TUI.blue);
removed = removePluginFromConfig(configPath, meta);
}
if (removed) {
TUI.step('Cleanup completed', 'DONE', TUI.blue);
} else {
TUI.step('No config entry found', 'SKIP', TUI.blue);
}
TUI.footer();
TUI.success(isTemplate ? 'Template successfully uninstalled.' : 'Plugin successfully uninstalled.');
} catch (err: any) {
TUI.step(packageName, 'FAIL');
TUI.footer();
TUI.error(`Could not remove ${packageName}`, formatSpawnError(err, cmdExe, 'remove', opts));
}
}
export {
installPlugin,
removePlugin
};