santifer/career-ops · error · Error
plugin name must match [a-z0-9-]
Error message
plugin name must match [a-z0-9-] (got "${name}") What it means
scaffoldNew validates the requested plugin name against /^[a-z0-9][a-z0-9-]*$/ before scaffolding from plugins/_template/. Lowercase alphanumerics and hyphens only, and it must not start with a hyphen. Any other name throws this error before any files are created.
Solutions
- Rename the plugin to lowercase kebab-case, e.g. 'my-cool-plugin'
- Strip or replace invalid characters: name.toLowerCase().replace(/[^a-z0-9-]/g, '-')
- Ensure the name doesn't start with a hyphen (regex requires an alphanumeric first char)
Example fix
// before await scaffoldNew(root, 'My_Plugin'); // Error: plugin name must match [a-z0-9-] (got "My_Plugin") // after const name = 'My_Plugin'.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+/, ''); await scaffoldNew(root, name); // 'my-plugin'
Defensive patterns
Strategy: validation
Validate before calling
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
if (!NAME_RE.test(name)) throw new Error(`invalid plugin name: ${name} (use lowercase kebab-case)`); Type guard
const isValidPluginName = (n) => typeof n === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(n);
Try / catch
try { await scaffoldNew(root, name); } catch (e) { if (e.message.includes('plugin name must match')) { name = slugify(name); return scaffoldNew(root, name); } throw e; } Prevention
- Always slugify user-supplied names before scaffolding
- Document the kebab-case constraint in your CLI's help text
- Sanitize in the UI layer so invalid names never reach the API
When it happens
Trigger: Calling scaffoldNew(root, name) with uppercase letters, underscores, spaces, dots, leading hyphen, or an empty/undefined name.
Common situations: Typing a plugin name in CamelCase or snake_case; passing a CLI arg with spaces; scaffolding with a version string or org prefix like '@scope/name'; forgetting to quote/normalize user input.
Understand the failure class
Background: "invalid id" errors: invalid identifier format — why libraries reject IDs before lookup, and how to fix them — this error's family across 37 libraries.
Related errors
- repo must be named "career-ops-plugin
- plugin rejected
- 4dayweek: invalid URL
- 4dayweek: URL must use HTTPS
- a 40-hex commit --sha is required
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/914b347149ddf4ef.
Report an issue: GitHub.
Appendix: source
Thrown at plugin-install.mjs:121
* audit is static. Returns problems (empty = clean).
* @returns {string[]}
*/
export function auditRegistryEntry(url, sha, expectId) {
let parsed;
try { parsed = parseRepoArg(url); } catch (e) { return [e.message]; }
if (expectId && parsed.id !== expectId) return [`repo "${url}" → id "${parsed.id}" but registry id is "${expectId}"`];
let dir;
try { dir = safeClone(parsed.url, sha); } catch (e) { return [e.message]; }
let result;
try { result = validateInstall(dir, parsed.id); }
catch (e) { rmSync(dir, { recursive: true, force: true }); return [e.message]; }
try { rmSync(result.dir || dir, { recursive: true, force: true }); } catch { /* best-effort */ }
return result.ok ? [] : result.problems;
}
/** Scaffold a new local plugin from plugins/_template/. */
export function scaffoldNew(root, name) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error(`plugin name must match [a-z0-9-] (got "${name}")`);
const tpl = path.join(root, 'plugins', '_template');
if (!existsSync(tpl)) throw new Error('plugins/_template/ not found');
const dest = path.join(root, 'plugins.local', name);
if (existsSync(dest)) throw new Error(`plugins.local/${name} already exists`);
mkdirSync(path.join(root, 'plugins.local'), { recursive: true });
cpSync(tpl, dest, { recursive: true });
// Substitute {{NAME}} placeholders in shipped text files.
const sub = (p) => { if (existsSync(p)) writeFileSync(p, readFileSync(p, 'utf8').replaceAll('{{NAME}}', name), 'utf8'); };
for (const f of readdirSync(dest, { withFileTypes: true })) {
if (f.isFile()) sub(path.join(dest, f.name));
}
if (existsSync(path.join(dest, 'test'))) for (const f of readdirSync(path.join(dest, 'test'))) sub(path.join(dest, 'test', f));
return dest;
}
// ── CLI flags + help ────────────────────────────────────────────────
//
// This file is a library first: plugins.mjs owns the user-facing `new`/`add`View on GitHub (pinned to aac998c7ed)