diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ca0af4a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Justin Ling + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index d194038..7a8ddc3 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "diffsplain", "version": "0.13.0", "description": "Review Git diffs one file at a time with coding agent notes beside each patch.", + "license": "MIT", "keywords": [ "codex", "claude", diff --git a/scripts/check.mjs b/scripts/check.mjs index 246fd60..2c89250 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -1,4 +1,5 @@ import { execFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { chmod, copyFile, @@ -68,6 +69,7 @@ const releaseTarball = : resolve(root, process.argv[releaseTarballIndex + 1]); const requiredPackageFiles = [ 'README.md', + 'LICENSE', 'package.json', 'dist/index.html', 'scripts/access-token.mjs', @@ -97,8 +99,28 @@ const requiredPackageFiles = [ ]; const allowedPackageFile = /^(README(?:\.md)?|LICENSE(?:\.md)?|NOTICE(?:\.md)?|package\.json|dist\/.+|scripts\/(?:access-token|agent-config|agent-exclusions|agent-note-output|agent-review|agent-usage|build-diff-data|cache|cli-args|coding-agents|dev|doctor|generate-summaries|local-target|mock-agent|present|presenter-runtime|review-chat(?:-context|-controller|-provider)?|serve-built|summary-path|support-record)\.mjs)$/; const privatePackageFile = /(^|\/)(?:\.env|\.npmrc|\.git|\.github|\.agents|\.codex)(?:\/|$)|\.(?:pem|key)$/i; +const mitLicenseSha256 = + '6a716032ab0de9dbd312e06686f5b89996de8908c65425fa48bd5dbb081444a7'; -export function validatePackageManifest(pack) { +function validateMitLicenseText(licenseText) { + if (licenseText.length === 0) { + throw new Error('Package manifest check failed: missing license text'); + } + if ( + createHash('sha256') + .update(licenseText.replaceAll('\r\n', '\n')) + .digest('hex') !== mitLicenseSha256 + ) { + throw new Error('Package manifest check failed: license text does not match MIT'); + } +} + +export function validatePackageManifest( + pack, + packageJson = {}, + licenseText = '', +) { + validateMitLicenseText(licenseText); const files = pack.files ?? []; const paths = new Set(files.map((file) => file.path)); const missing = requiredPackageFiles.filter((path) => !paths.has(path)); @@ -118,6 +140,15 @@ export function validatePackageManifest(pack) { }, { present: oversizedPackage, text: 'package exceeds 12 MB' }, { present: oversizedFile, text: 'file exceeds 1 MB' }, + { + present: packageJson.license === undefined, + text: 'missing package license', + }, + { + present: + packageJson.license !== undefined && packageJson.license !== 'MIT', + text: `license ${packageJson.license} does not match MIT`, + }, ] .filter((problem) => problem.present) .map((problem) => problem.text); @@ -200,6 +231,14 @@ function verifySmokeResults({ packageJson, version, help, doctor, runtime }) { } } +function readPackedLicense(pack, consumerRoot) { + if (!pack.files?.some((file) => file.path === 'LICENSE')) return ''; + return readFile( + join(consumerRoot, 'node_modules/diffsplain/LICENSE'), + 'utf8', + ); +} + async function smokeTestPackage() { const packageRoot = await mkdtemp(join(tmpdir(), 'diffsplain-package-')); const consumerRoot = join(packageRoot, 'consumer'); @@ -220,7 +259,6 @@ async function smokeTestPackage() { const tarball = isAbsolute(pack.filename) ? pack.filename : join(packageRoot, pack.filename); - validatePackageManifest(pack); await mkdir(consumerRoot); await writeFile( join(consumerRoot, 'package.json'), @@ -234,6 +272,8 @@ async function smokeTestPackage() { const packageJson = JSON.parse( await readFile(join(consumerRoot, 'node_modules/diffsplain/package.json')), ); + const licenseText = await readPackedLicense(pack, consumerRoot); + validatePackageManifest(pack, packageJson, licenseText); const executable = resolve( consumerRoot, 'node_modules/diffsplain', diff --git a/tests/package-manifest.test.mjs b/tests/package-manifest.test.mjs index f4cf093..03c2163 100644 --- a/tests/package-manifest.test.mjs +++ b/tests/package-manifest.test.mjs @@ -1,9 +1,13 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { validatePackageManifest } from '../scripts/check.mjs'; +const mitLicense = await readFile(new URL('../LICENSE', import.meta.url), 'utf8'); + const requiredFiles = [ 'README.md', + 'LICENSE', 'package.json', 'dist/index.html', 'scripts/access-token.mjs', @@ -36,29 +40,63 @@ function manifest(files = requiredFiles.map((path) => ({ path, size: 1 }))) { return { files, unpackedSize: 1 }; } +function validate( + pack = manifest(), + packageJson = { license: 'MIT' }, + licenseText = mitLicense, +) { + return validatePackageManifest(pack, packageJson, licenseText); +} + test('accepts the required package manifest', () => { - assert.doesNotThrow(() => validatePackageManifest(manifest())); + assert.doesNotThrow(() => validate()); + assert.doesNotThrow(() => + validate(manifest(), { license: 'MIT' }, mitLicense.replaceAll('\n', '\r\n')), + ); }); test('rejects missing, private, unexpected, and oversized package files', () => { assert.throws( - () => validatePackageManifest(manifest(requiredFiles.slice(1).map((path) => ({ path, size: 1 })))), + () => validate(manifest(requiredFiles.slice(1).map((path) => ({ path, size: 1 })))), /missing README\.md/, ); assert.throws( - () => validatePackageManifest(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: '.env', size: 1 }])), + () => validate(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: '.env', size: 1 }])), /private .env/, ); assert.throws( - () => validatePackageManifest(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: 'notes.txt', size: 1 }])), + () => validate(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: 'notes.txt', size: 1 }])), /unexpected notes\.txt/, ); assert.throws( - () => validatePackageManifest(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: 'dist/large.js', size: 1_000_001 }])), + () => validate(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: 'dist/large.js', size: 1_000_001 }])), /file exceeds 1 MB/, ); assert.throws( - () => validatePackageManifest({ ...manifest(), unpackedSize: 12_000_001 }), + () => validate({ ...manifest(), unpackedSize: 12_000_001 }), /package exceeds 12 MB/, ); }); + +test('rejects missing license text or metadata that is not MIT', () => { + assert.throws( + () => validate(manifest(requiredFiles.filter((path) => path !== 'LICENSE').map((path) => ({ path, size: 1 })))), + /missing LICENSE/, + ); + assert.throws( + () => validate(manifest(), {}), + /missing package license/, + ); + assert.throws( + () => validate(manifest(), { license: 'Apache-2.0' }), + /license Apache-2\.0 does not match MIT/, + ); + assert.throws( + () => validate(manifest(), { license: 'MIT' }, ''), + /missing license text/, + ); + assert.throws( + () => validate(manifest(), { license: 'MIT' }, `${mitLicense}extra terms\n`), + /license text does not match MIT/, + ); +});