chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user