Files
upstream-tldraw/internal/scripts/check-packages.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

619 lines
18 KiB
TypeScript

import { existsSync } from 'fs'
import path, { join, relative } from 'path'
import kleur from 'kleur'
import {
REPO_ROOT,
readFileIfExists,
readJsonIfExists,
writeCodeFile,
writeJsonFile,
writeStringFile,
} from './lib/file'
import { nicelog } from './lib/nicelog'
import { PRODUCT_CONFIG_KEY } from './lib/types'
import { Package, getAllWorkspacePackages, getRootPackage } from './lib/workspace'
const packagesWithoutTSConfigs: ReadonlySet<string> = new Set(['config'])
// all packages should have these scripts
const expectedPackageJsonScriptsForAll = {
lint: tsScript('lint.ts'),
}
const expectedTestScripts = {
test: () => 'yarn run -T vitest --passWithNoTests',
'test-ci': () => 'yarn run -T vitest run --passWithNoTests',
'test-coverage': () => 'yarn run -T vitest run --coverage --passWithNoTests',
}
// packages (in packages/) should have these scripts
const expectedPackageJsonScriptsForLibraries = {
...expectedPackageJsonScriptsForAll,
...expectedTestScripts,
}
const expectedPackageJsonScriptsForApps = {
...expectedPackageJsonScriptsForAll,
...expectedTestScripts,
}
// published packages should have these scripts
const expectedPackageJsonScriptsForPublishedLibraries = {
...expectedPackageJsonScriptsForLibraries,
build: tsScript('build-package.ts'),
'build-api': tsScript('build-api.ts'),
prepack: tsScript('prepack.ts'),
postpack: (packageDir: string) => scriptPath(packageDir, 'postpack.sh'),
'pack-tarball': () => 'yarn pack',
}
// individual packages can have different scripts than the above if needed
const packageJsonScriptExceptions: Record<string, Record<string, () => string | undefined>> = {
config: {
lint: () => undefined,
},
tsconfig: {
lint: () => undefined,
},
'@tldraw/monorepo': {
lint: () => undefined,
},
'@tldraw/assets': {
test: () => undefined,
'test-ci': () => undefined,
build: () => undefined,
'build-api': () => undefined,
prepack: () => undefined,
postpack: () => undefined,
},
'create-tldraw': {
build: () => './scripts/build.sh',
'build-api': () => undefined,
prepack: () => 'yarn build',
postpack: () => undefined,
},
}
async function checkPackageJsonScripts({
packages,
fix,
}: {
packages: Package[]
fix: boolean
}): Promise<boolean> {
const needsFix = new Set()
let errorCount = 0
for (const { path: packageDir, relativePath, packageJson, name } of packages) {
if (!packageJson.scripts) {
packageJson.scripts = {}
}
const packageScripts = packageJson.scripts
let expected = relativePath.startsWith('packages/')
? packageJson.private
? expectedPackageJsonScriptsForLibraries
: expectedPackageJsonScriptsForPublishedLibraries
: relativePath.startsWith('apps/')
? expectedPackageJsonScriptsForApps
: expectedPackageJsonScriptsForAll
if (packageJsonScriptExceptions[name]) {
expected = {
...expected,
...packageJsonScriptExceptions[name],
}
}
for (const [scriptName, getExpectedScript] of Object.entries(expected)) {
const actualScript = packageScripts[scriptName]
const expectedScript = getExpectedScript(packageDir)
if (actualScript !== expectedScript) {
nicelog(
[
'❌ ',
kleur.red(`${name}: `),
kleur.blue(`$ yarn ${scriptName}`),
kleur.grey(' -> '),
kleur.red(actualScript ?? '<missing>'),
kleur.gray(' (expected: '),
kleur.green(expectedScript),
kleur.gray(')'),
].join('')
)
packageScripts[scriptName] = expectedScript
needsFix.add(name)
errorCount++
} else {
nicelog(
[
'✅ ',
kleur.green(`${name}: `),
kleur.blue(`$ yarn ${scriptName}`),
kleur.grey(' -> '),
kleur.green(actualScript ?? '<missing>'),
].join('')
)
}
}
}
if (errorCount) {
if (fix) {
for (const { packageJson, name, relativePath } of packages) {
if (needsFix.has(name)) {
nicelog(kleur.yellow(`Fixing ${name}...`))
await writeJsonFile(path.join(REPO_ROOT, relativePath, 'package.json'), packageJson)
}
}
nicelog(kleur.yellow(`Fixed ${errorCount} errors`))
return true
} else {
nicelog(kleur.red(`Found ${errorCount} errors`))
return false
}
}
return true
}
async function checkTsConfigs({
packages,
fix,
}: {
fix: boolean
packages: Package[]
}): Promise<boolean> {
let numErrors = 0
for (const workspace of packages) {
const tsconfigPath = join(workspace.path, 'tsconfig.json')
if (packagesWithoutTSConfigs.has(workspace.name)) {
continue
}
const tsconfig = (await readJsonIfExists(tsconfigPath)) as {
references?: { path: string }[]
}
if (!tsconfig) {
throw new Error('No tsconfig.json found at ' + tsconfigPath)
}
const tldrawDeps = Object.keys({
...workspace.packageJson.dependencies,
...workspace.packageJson.devDependencies,
}).filter((dep) => packages.some((p) => p.name === dep))
const fixedDeps = []
const missingRefs = []
const currentRefs = new Set<string>([...(tsconfig.references?.map((ref) => ref.path) ?? [])])
for (const dep of tldrawDeps) {
// construct the expected path to the dependency's tsconfig
const matchingWorkspace = packages.find((p) => p.name === dep)
if (!matchingWorkspace) {
throw new Error(`No workspace found for ${dep}`)
}
const tsconfigReferencePath = relative(workspace.path, matchingWorkspace.path)
fixedDeps.push({ path: tsconfigReferencePath })
if (currentRefs.has(tsconfigReferencePath)) {
currentRefs.delete(tsconfigReferencePath)
} else {
missingRefs.push(dep)
}
}
fixedDeps.sort((a, b) => a.path.localeCompare(b.path))
if (currentRefs.size > 0) {
if (fix) {
tsconfig.references = fixedDeps
await writeJsonFile(tsconfigPath, tsconfig)
} else {
numErrors++
nicelog(
[
'❌ ',
kleur.red(`${workspace.name}: `),
kleur.blue(relative(process.cwd(), tsconfigPath)),
kleur.grey(' has unnecessary reference(s) to '),
kleur.red([...currentRefs].join(', ')),
].join('')
)
}
}
if (missingRefs.length) {
if (fix) {
tsconfig.references = fixedDeps
await writeJsonFile(tsconfigPath, tsconfig)
} else {
numErrors++
nicelog(
[
'❌ ',
kleur.red(`${workspace.name}: `),
kleur.blue(relative(process.cwd(), tsconfigPath)),
kleur.grey(' is missing reference(s) to '),
kleur.red(missingRefs.join(', ')),
].join('')
)
nicelog('The references entry should look like this:')
nicelog('"references": ' + JSON.stringify(fixedDeps, null, 2))
}
}
if (currentRefs.size === 0 && missingRefs.length === 0) {
nicelog(['✅ ', kleur.green(`${workspace.name}`)].join(''))
}
}
if (numErrors > 0) {
nicelog('Run `yarn check-tsconfigs --fix` to fix these problems')
return false
}
return true
}
function scriptPath(packageDir: string, scriptName: string) {
return path.relative(packageDir, path.join(__dirname, scriptName))
}
function tsScript(scriptName: string) {
return (packageDir: string) => `yarn run -T tsx ${scriptPath(packageDir, scriptName)}`
}
async function checkLibraryContents({
fix,
packages,
}: {
fix: boolean
packages: Package[]
}): Promise<boolean> {
let errorCount = 0
for (const { packageJson, name, path } of packages) {
if (packageJson.private) continue
const sourceFilePath = join(path, 'src', 'index.ts')
const sourceFileContents = await readFileIfExists(sourceFilePath)
if (!sourceFileContents) {
nicelog(['⏩ ', kleur.blue(`${name}: `), 'skipped (no src/index.ts)'].join(''))
continue
}
const search = [
'registerTldrawLibraryVersion(',
'\t(globalThis as any).TLDRAW_LIBRARY_NAME,',
'\t(globalThis as any).TLDRAW_LIBRARY_VERSION,',
'\t(globalThis as any).TLDRAW_LIBRARY_MODULES',
')',
].join('\n')
if (sourceFileContents.includes(search)) {
nicelog(['✅ ', kleur.green(name)].join(''))
continue
}
errorCount++
if (fix) {
const newSourceFileContents = [
"import {registerTldrawLibraryVersion} from '@tldraw/utils'",
sourceFileContents,
'',
'registerTldrawLibraryVersion(',
'(globalThis as any).TLDRAW_LIBRARY_NAME,',
'(globalThis as any).TLDRAW_LIBRARY_VERSION,',
'(globalThis as any).TLDRAW_LIBRARY_NAME',
')',
].join('\n')
await writeCodeFile(null, 'typescript', sourceFilePath, newSourceFileContents)
nicelog(
[
'⚠️ ',
kleur.yellow(`${name}: `),
'added call to ',
kleur.blue('registerTldrawLibraryVersion'),
].join('')
)
} else {
nicelog(
[
'❌ ',
kleur.red(`${name}: `),
'missing call to ',
kleur.blue('registerTldrawLibraryVersion'),
].join('')
)
}
}
if (errorCount) {
if (fix) {
nicelog(kleur.yellow(`Fixed ${errorCount} errors`))
return true
} else {
nicelog(kleur.red(`Found ${errorCount} errors`))
return false
}
}
return true
}
const LICENSE_POINTER_CONTENTS =
'This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)\n'
// The stable id convention is documented in internal/docs/product-stable-ids.md. Every published
// package in packages/ must declare which commercial component it belongs to, and premium
// components must name the license key flag that entitles them.
async function checkProductMetadata({
packages,
fix,
}: {
packages: Package[]
fix: boolean
}): Promise<boolean> {
let errorCount = 0
const error = (name: string, message: string) => {
errorCount++
nicelog(['❌ ', kleur.red(`${name}: `), message].join(''))
}
const licenseManagerSource = await readFileIfExists(
join(REPO_ROOT, 'packages/editor/src/lib/license/LicenseManager.ts')
)
if (!licenseManagerSource) {
throw new Error('Could not read LicenseManager.ts to validate license flags')
}
const knownLicenseFlags = new Set(
[...licenseManagerSource.matchAll(/^\t(FEAT_[A-Z0-9_]+):/gm)].map((m) => m[1])
)
const byStableId = new Map<string, { name: string; config: string }[]>()
for (const { packageJson, name, relativePath, path: packageDir } of packages) {
if (!relativePath.startsWith('packages/') || packageJson.private) continue
// published packages that point at LICENSE.md must actually ship one, since npm only
// includes license files that exist inside the package directory
if (packageJson.license === 'SEE LICENSE IN LICENSE.md') {
const licensePath = join(packageDir, 'LICENSE.md')
if (!existsSync(licensePath)) {
if (fix) {
await writeStringFile(licensePath, LICENSE_POINTER_CONTENTS)
nicelog(['⚠️ ', kleur.yellow(`${name}: `), 'added missing LICENSE.md'].join(''))
} else {
error(name, 'is missing LICENSE.md (run `yarn check-packages --fix`)')
}
}
}
const product = packageJson[PRODUCT_CONFIG_KEY]
if (!product) {
error(
name,
`is missing the "${PRODUCT_CONFIG_KEY}" field in package.json. ` +
'See internal/docs/product-stable-ids.md for how to choose a stable id.'
)
continue
}
if (!/^tldraw:[a-z0-9-]+$/.test(product.stableId)) {
error(name, `has invalid stableId "${product.stableId}" (expected tldraw:<kebab-case>)`)
}
if (product.premium && !product.licenseFlag) {
error(name, 'is premium but has no licenseFlag')
}
if (!product.premium && product.licenseFlag) {
error(name, 'has a licenseFlag but is not premium')
}
if (product.licenseFlag && !knownLicenseFlags.has(product.licenseFlag)) {
error(
name,
`has licenseFlag "${product.licenseFlag}" which does not exist in LicenseManager FLAGS`
)
}
if (product.type === 'feature' && !product.parent) {
error(name, 'is a feature but has no parent component')
}
if (product.type === 'product' && product.parent) {
error(name, 'is a top-level product but declares a parent')
}
if (product.parent === product.stableId) {
error(name, 'is its own parent')
}
// all packages sharing a stable id must agree on the rest of the metadata, since they
// describe the same commercial component
const configKey = JSON.stringify([
product.name,
product.type,
product.parent ?? null,
product.premium,
product.licenseFlag ?? null,
])
const others = byStableId.get(product.stableId) ?? []
others.push({ name, config: configKey })
byStableId.set(product.stableId, others)
}
for (const [stableId, entries] of byStableId) {
if (new Set(entries.map((e) => e.config)).size > 1) {
error(
stableId,
`packages disagree on product metadata: ${entries.map((e) => e.name).join(', ')}`
)
}
}
// every parent must be a component that actually exists, or the order form would reference a
// line item nothing defines
for (const { packageJson, name, relativePath } of packages) {
if (!relativePath.startsWith('packages/') || packageJson.private) continue
const parent = packageJson[PRODUCT_CONFIG_KEY]?.parent
if (parent && !byStableId.has(parent)) {
error(name, `has parent "${parent}" which is not a known component`)
}
}
if (errorCount) {
nicelog(kleur.red(`Found ${errorCount} errors`))
return false
}
nicelog(['✅ ', kleur.green('product metadata ok')].join(''))
return true
}
async function group<T>(name: string, cb: () => Promise<T>) {
console.group(name)
try {
return await cb()
} finally {
console.groupEnd()
console.log('')
}
}
// Every workspace uses the same version range for a dependency, so the lockfile resolves one
// copy. To let a dependency diverge, list the workspaces that step off the shared range here:
// they must agree with each other, and everything else must agree with everything else.
const ALLOWED_VERSION_DIVERGENCE: Record<string, { workspaces: string[]; reason: string }> = {
typescript: {
workspaces: ['templates/', 'apps/mcp-app'],
reason:
"templates are independently published starters, and mcp-app's extract-editor-api.ts " +
"needs the TS 5 compiler API that TS 7 doesn't export",
},
}
// Node 22.12 is the first version where `require()` of an ES module works unflagged, so
// published packages can depend on ESM-only modules without breaking CommonJS consumers.
// Earlier versions throw `ERR_REQUIRE_ESM`.
const PUBLISHED_NODE_ENGINE = '>=22.12.0'
const DEPENDENCY_FIELDS = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
] as const
type DependencyField = (typeof DEPENDENCY_FIELDS)[number]
async function checkDependencyVersions({
packages,
fix,
}: {
packages: Package[]
fix: boolean
}): Promise<boolean> {
let errorCount = 0
const changed = new Set<Package>()
const report = (name: string, message: string) => {
errorCount++
nicelog([fix ? '⚠️ ' : '❌ ', (fix ? kleur.yellow : kleur.red)(`${name}: `), message].join(''))
}
const root = await getRootPackage()
// Peer ranges are deliberately wider than installed ranges, so they're only compared with
// each other.
const usages = new Map<string, { pkg: Package; field: DependencyField; range: string }[]>()
for (const pkg of [root, ...packages]) {
for (const field of DEPENDENCY_FIELDS) {
for (const [dep, range] of Object.entries(pkg.packageJson[field] ?? {})) {
const diverges = ALLOWED_VERSION_DIVERGENCE[dep]?.workspaces.some((prefix) =>
pkg.relativePath.startsWith(prefix)
)
const kind = field === 'peerDependencies' ? 'peer' : 'installed'
const key = `${dep}\0${kind}\0${diverges ? 'diverged' : 'shared'}`
if (!usages.has(key)) usages.set(key, [])
usages.get(key)!.push({ pkg, field, range })
}
}
}
for (const [key, group] of usages) {
const counts = new Map<string, number>()
for (const { range } of group) counts.set(range, (counts.get(range) ?? 0) + 1)
if (counts.size === 1) continue
const dep = key.split('\0')[0]
const [expected] = [...counts].sort((a, b) => b[1] - a[1])[0]
for (const { pkg, field, range } of group) {
if (range === expected) continue
report(pkg.name, `${field}.${dep} is ${range}, but other workspaces use ${expected}`)
if (fix) {
pkg.packageJson[field]![dep] = expected
changed.add(pkg)
}
}
}
for (const pkg of packages) {
if ('packageManager' in pkg.packageJson) {
report(pkg.name, 'only the root package.json may set packageManager')
if (fix) {
delete pkg.packageJson.packageManager
changed.add(pkg)
}
}
if (!pkg.relativePath.startsWith('packages/')) continue
const engines = pkg.packageJson.engines ?? {}
if (engines.node !== PUBLISHED_NODE_ENGINE) {
report(pkg.name, `engines.node must be ${PUBLISHED_NODE_ENGINE}`)
if (fix) {
pkg.packageJson.engines = { ...engines, node: PUBLISHED_NODE_ENGINE }
changed.add(pkg)
}
}
}
for (const pkg of changed) {
await writeJsonFile(join(pkg.path, 'package.json'), pkg.packageJson)
}
if (errorCount) {
nicelog(
fix
? kleur.yellow(`Fixed ${errorCount} errors. Run \`yarn\` to update the lockfile.`)
: kleur.red(`Found ${errorCount} errors. Run \`yarn check-packages --fix\` to fix them.`)
)
return fix
}
nicelog(' ✅ dependency versions ok')
return true
}
async function main({ fix }: { fix: boolean }) {
const packages = await getAllWorkspacePackages()
const scriptsOk = await group('Checking package.json scripts...', () =>
checkPackageJsonScripts({ packages, fix })
)
const tsConfigsOk = await group('Checking tsconfig.json files...', () =>
checkTsConfigs({ packages, fix })
)
const libsOk = await group('Checking library source files...', () =>
checkLibraryContents({ packages, fix })
)
const productMetadataOk = await group('Checking product metadata...', () =>
checkProductMetadata({ packages, fix })
)
const dependencyVersionsOk = await group('Checking dependency versions...', () =>
checkDependencyVersions({ packages, fix })
)
if (!scriptsOk || !tsConfigsOk || !libsOk || !productMetadataOk || !dependencyVersionsOk) {
process.exit(1)
}
}
main({
fix: process.argv.includes('--fix'),
})