#!/usr/bin/env node 'use strict'; /** * Single source for MSD's published-package coordinates (issue #498). * * `deriveIdentity(pkg)` is the pure core: it turns a parsed package.json into * the coordinate record every consumer needs. The generated runtime module * `msd-core/bin/lib/package-identity.cjs` bakes those values at build * time, because the installed tree carries no package.json with a `.name` — * the only ones MSD stages are synthetic `{"type":"commonjs"}` markers, which * since #2544 live inside MSD's own directories (`hooks/`, and the native * plugin dir) rather than at the config root — so a runtime * `require('package.json').name` resolves to `undefined` (the #378 bug this * seam retires). Baking from package.json reconciles #378 (renames survive) * with #2992 (the value is never an LLM runtime choice). */ /** * Parse `owner/name` out of a package.json `repository.url`, stripping the * `git+` prefix and `.git` suffix npm conventionally adds. */ function parseRepo(repository) { const url = typeof repository === 'string' ? repository : (repository && repository.url) || ''; const m = url.replace(/^git\+/, '').replace(/\.git$/, '').match(/(github\.com|git\.golem15\.com)[/:]([^/]+\/[^/]+)$/); return m ? { host: m[1], slug: m[2] } : { host: '', slug: '' }; } function parseRepoSlug(repository) { return parseRepo(repository).slug; } /** * Raw-file URL for CHANGELOG.md on the repo's default branch. GitHub serves raw * files from a separate host; the golem15 Gitea forge serves them in-place. */ function changelogRawUrlFor(host, slug) { if (!slug) return ''; if (host === 'github.com') return `https://raw.githubusercontent.com/${slug}/main/CHANGELOG.md`; return `https://${host}/${slug}/raw/branch/next/CHANGELOG.md`; } /** * Pure: turn an npm package name into a filesystem-safe slug for cache filenames. * Strips a leading `@`, replaces `/` with `-`, then collapses any run of * characters that are NOT `[a-z0-9]` to a single `-`, and trims leading/trailing `-`. */ function slugifyPackageName(name) { if (!name) return ''; return name .replace(/^@/, '') .replace(/\//g, '-') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } /** * Pure: package.json object -> the package identity coordinates. */ function deriveIdentity(pkg = {}) { const packageName = pkg.name || ''; const binName = pkg.bin ? Object.keys(pkg.bin)[0] || '' : ''; const { host, slug: repoSlug } = parseRepo(pkg.repository); const repoUrl = repoSlug ? `https://${host}/${repoSlug}` : ''; const changelogRawUrl = changelogRawUrlFor(host, repoSlug); const cacheSlug = slugifyPackageName(packageName); const updateCacheFileName = cacheSlug ? `msd-update-check-${cacheSlug}.json` : 'msd-update-check.json'; return { packageName, binName, repoSlug, repoUrl, changelogRawUrl, cacheSlug, updateCacheFileName }; } /** * Pure: format the `npx` fallback install command. Shape matches the literal * the update workflow embeds: `npx -y --package=@latest -- * [--] --`. The runtime flag is omitted when not supplied. * * This function is the canonical source — `render()` serializes it verbatim * into the generated module, so the runtime copy can never drift from it. */ function formatManualInstall({ packageName, binName, scope, runtime } = {}) { const runtimeFlag = runtime ? ` --${runtime}` : ''; return `npx -y --package=${packageName}@latest -- ${binName}${runtimeFlag} --${scope}`; } const GENERATED_HEADER = '// @generated by scripts/generate-package-identity.cjs from package.json — DO NOT EDIT.\n' + '// Single source for MSD package coordinates (issue #498). Regenerate with:\n' + '// node scripts/generate-package-identity.cjs\n'; /** * Render the generated runtime module text for a derived identity. Values are * baked as literals; `formatManualInstall` is embedded by `.toString()` so the * runtime command builder is byte-identical to the tested source above. */ function render(identity) { const { packageName, binName, repoSlug, repoUrl, changelogRawUrl, cacheSlug, updateCacheFileName } = identity; const j = (v) => JSON.stringify(v); return ( GENERATED_HEADER + "'use strict';\n\n" + `const packageName = ${j(packageName)};\n` + `const binName = ${j(binName)};\n` + `const repoSlug = ${j(repoSlug)};\n` + `const repoUrl = ${j(repoUrl)};\n` + `const changelogRawUrl = ${j(changelogRawUrl)};\n` + `const cacheSlug = ${j(cacheSlug)};\n` + `const updateCacheFileName = ${j(updateCacheFileName)};\n\n` + `${formatManualInstall.toString()}\n\n` + 'function manualInstallCommand(opts = {}) {\n' + ' return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });\n' + '}\n\n' + 'module.exports = Object.freeze({\n' + ' packageName,\n' + ' // PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it\n' + " // survives the installed tree's synthetic package.json (fixes the #378 undefined).\n" + ' PACKAGE_NAME: packageName,\n' + ' binName,\n' + ' repoSlug,\n' + ' repoUrl,\n' + ' changelogRawUrl,\n' + ' cacheSlug,\n' + ' updateCacheFileName,\n' + ' manualInstallCommand,\n' + '});\n' ); } function main() { const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); const check = args.includes('--check'); const pkg = require(path.join(__dirname, '..', 'package.json')); const out = path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'package-identity.cjs'); const rendered = render(deriveIdentity(pkg)); if (check) { // Compare normalized content so a CRLF checkout (Windows, no .gitattributes // eol rule) does not register as stale. Mirrors the in-process check the // unit suite used to perform (issue #498) and the convention used by the // other gen-* --check generators. const norm = (s) => s.replace(/\r\n/g, '\n'); let committed = ''; try { committed = fs.readFileSync(out, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; } if (norm(committed) !== norm(rendered)) { process.stderr.write('package-identity.cjs is stale — run: node scripts/generate-package-identity.cjs\n'); return 1; } return 0; } fs.writeFileSync(out, rendered); process.stdout.write(`wrote ${path.relative(path.join(__dirname, '..'), out)}\n`); return 0; } if (require.main === module) { const code = main(); if (typeof code === 'number' && code !== 0) process.exitCode = code; } module.exports = { deriveIdentity, parseRepoSlug, slugifyPackageName, formatManualInstall, render, main };