snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
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
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
This commit is contained in:
77
internal/scripts/lib/add-extensions.ts
Normal file
77
internal/scripts/lib/add-extensions.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { glob } from 'glob'
|
||||
import { parse, print, visit } from 'recast'
|
||||
|
||||
const extensions = ['.js', '.mjs', '.cjs']
|
||||
function resolveRelativePath(importingFile: string, relativePath: string) {
|
||||
if (!relativePath.startsWith('.')) {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
const containingDir = path.dirname(importingFile)
|
||||
|
||||
if (
|
||||
existsSync(path.join(containingDir, relativePath)) &&
|
||||
!statSync(path.join(containingDir, relativePath)).isDirectory()
|
||||
) {
|
||||
// if the file already exists, e.g. .css files, just use it
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// strip the file extension if applicable
|
||||
relativePath.replace(/\.(m|c)?js$/, '')
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (relativePath.endsWith(extension)) {
|
||||
return relativePath
|
||||
} else {
|
||||
let candidate = `${relativePath}${extension}`
|
||||
if (existsSync(path.join(containingDir, candidate))) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
candidate = `${relativePath}/index${extension}`
|
||||
|
||||
if (existsSync(path.join(containingDir, candidate))) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not resolve relative path ${relativePath} from ${importingFile}`)
|
||||
}
|
||||
|
||||
export function addJsExtensions(distDir: string) {
|
||||
for (const file of glob.sync(path.join(distDir, '**/*.{mjs,cjs,js}'))) {
|
||||
const code = parse(readFileSync(file, 'utf8'), { parser: require('recast/parsers/typescript') })
|
||||
|
||||
visit(code, {
|
||||
visitImportDeclaration(path) {
|
||||
path.value.source.value = resolveRelativePath(file, path.value.source.value)
|
||||
return false
|
||||
},
|
||||
visitExportAllDeclaration(path) {
|
||||
path.value.source.value = resolveRelativePath(file, path.value.source.value)
|
||||
return false
|
||||
},
|
||||
visitExportNamedDeclaration(path) {
|
||||
if (path.value.source) {
|
||||
path.value.source.value = resolveRelativePath(file, path.value.source.value)
|
||||
}
|
||||
this.traverse(path)
|
||||
},
|
||||
visitCallExpression(path) {
|
||||
if (
|
||||
path.value.callee.type === 'Import' &&
|
||||
path.value.arguments[0]?.type === 'StringLiteral'
|
||||
) {
|
||||
path.value.arguments[0].value = resolveRelativePath(file, path.value.arguments[0].value)
|
||||
}
|
||||
this.traverse(path)
|
||||
},
|
||||
})
|
||||
|
||||
writeFileSync(file, print(code).code)
|
||||
}
|
||||
}
|
||||
217
internal/scripts/lib/deploy.ts
Normal file
217
internal/scripts/lib/deploy.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import * as github from '@actions/github'
|
||||
import { exec } from './exec'
|
||||
|
||||
export function getDeployInfo() {
|
||||
const githubPrNumber = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]
|
||||
|
||||
let previewId = process.env.TLDRAW_PREVIEW_ID
|
||||
if (!previewId && process.env.TLDRAW_ENV === 'preview') {
|
||||
if (githubPrNumber) {
|
||||
previewId = `pr-${githubPrNumber}`
|
||||
} else {
|
||||
throw new Error(
|
||||
'If running preview deploys from outside of a PR action, TLDRAW_PREVIEW_ID env var must be set'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const sha: string | undefined =
|
||||
// if the event is 'pull_request', github.context.sha is an ephemeral merge commit
|
||||
// while the actual commit we want to create the deployment for is the 'head' of the PR.
|
||||
github.context.eventName === 'pull_request'
|
||||
? github.context.payload.pull_request?.head.sha
|
||||
: github.context.sha
|
||||
|
||||
if (!sha) {
|
||||
throw new Error('Could not determine the SHA of the commit to deploy')
|
||||
}
|
||||
|
||||
return {
|
||||
githubPrNumber,
|
||||
previewId,
|
||||
sha,
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a github 'deployment', which creates a 'View Deployment' button in the PR timeline.
|
||||
export async function createGithubDeployment(
|
||||
env: { GH_TOKEN: string; TLDRAW_ENV: string },
|
||||
{
|
||||
app,
|
||||
deploymentUrl,
|
||||
inspectUrl,
|
||||
sha,
|
||||
}: { app: string; deploymentUrl: string; inspectUrl?: string; sha: string }
|
||||
) {
|
||||
const client = github.getOctokit(env.GH_TOKEN)
|
||||
|
||||
const deployment = await client.rest.repos.createDeployment({
|
||||
owner: 'tldraw',
|
||||
repo: 'tldraw',
|
||||
ref: sha,
|
||||
payload: { web_url: deploymentUrl },
|
||||
environment: `${app}-${env.TLDRAW_ENV}`,
|
||||
transient_environment: env.TLDRAW_ENV === 'preview',
|
||||
production_environment: env.TLDRAW_ENV === 'production',
|
||||
required_contexts: [],
|
||||
auto_merge: false,
|
||||
task: 'deploy',
|
||||
})
|
||||
|
||||
await client.rest.repos.createDeploymentStatus({
|
||||
owner: 'tldraw',
|
||||
repo: 'tldraw',
|
||||
deployment_id: (deployment.data as any).id,
|
||||
state: 'success',
|
||||
environment_url: deploymentUrl,
|
||||
log_url: inspectUrl,
|
||||
})
|
||||
}
|
||||
|
||||
/** Deploy a worker to wrangler, returning the deploy ID */
|
||||
export async function wranglerDeploy({
|
||||
location,
|
||||
dryRun,
|
||||
env,
|
||||
vars,
|
||||
sentry,
|
||||
}: {
|
||||
location: string
|
||||
dryRun: boolean
|
||||
env: string
|
||||
vars: Record<string, string>
|
||||
sentry?: {
|
||||
authToken: string
|
||||
project: string
|
||||
environment?: string
|
||||
}
|
||||
}) {
|
||||
const varsArray = []
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
varsArray.push('--var', `${key}:${value}`)
|
||||
}
|
||||
|
||||
const out = await exec(
|
||||
'yarn',
|
||||
[
|
||||
'wrangler',
|
||||
'deploy',
|
||||
dryRun ? '--dry-run' : null,
|
||||
'--env',
|
||||
env,
|
||||
'--outdir',
|
||||
'.wrangler/dist',
|
||||
...varsArray,
|
||||
],
|
||||
{
|
||||
pwd: location,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
|
||||
CI: '1',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (dryRun) return
|
||||
|
||||
const versionMatch = out.match(/Current Version ID: (.+)/)
|
||||
if (!versionMatch) {
|
||||
throw new Error('Could not find the deploy ID in wrangler output')
|
||||
}
|
||||
|
||||
const workerNameMatch = out.match(/Uploaded ([^ ]+)/)
|
||||
|
||||
if (!workerNameMatch) {
|
||||
throw new Error('Could not find the worker name in wrangler output')
|
||||
}
|
||||
|
||||
if (sentry) {
|
||||
const release = `${sentry.environment ?? workerNameMatch[1]}.${versionMatch[1]}`
|
||||
|
||||
const sentryEnv = {
|
||||
SENTRY_AUTH_TOKEN: sentry.authToken,
|
||||
SENTRY_ORG: 'tldraw',
|
||||
SENTRY_PROJECT: sentry.project,
|
||||
}
|
||||
|
||||
// create a sentry release:
|
||||
await exec('yarn', ['run', '-T', 'sentry-cli', 'releases', 'new', release], {
|
||||
pwd: location,
|
||||
env: sentryEnv,
|
||||
})
|
||||
|
||||
// upload sourcemaps to the release:
|
||||
await exec(
|
||||
'yarn',
|
||||
[
|
||||
'run',
|
||||
'-T',
|
||||
'sentry-cli',
|
||||
'releases',
|
||||
'files',
|
||||
release,
|
||||
'upload-sourcemaps',
|
||||
'.wrangler/dist',
|
||||
],
|
||||
{
|
||||
pwd: location,
|
||||
env: sentryEnv,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceBinding {
|
||||
binding: string
|
||||
service: string
|
||||
}
|
||||
|
||||
export async function setWranglerPreviewConfig(
|
||||
location: string,
|
||||
{
|
||||
name,
|
||||
routeHostname,
|
||||
serviceBinding,
|
||||
}: { name: string; routeHostname?: string; serviceBinding?: ServiceBinding },
|
||||
queueName?: string
|
||||
) {
|
||||
// A zone route instead of a custom domain: custom domains mint a dedicated
|
||||
// certificate pack per hostname, while routes are covered by the zone's
|
||||
// universal wildcard cert and the existing proxied *.tldraw.xyz DNS record.
|
||||
const zoneName = routeHostname?.split('.').slice(-2).join('.')
|
||||
const additionalProperties = `name = "${name}"
|
||||
${routeHostname ? `routes = [ { pattern = "${routeHostname}/*", zone_name = "${zoneName}" } ]` : ''}
|
||||
${serviceBinding ? `services = [ {binding = "${serviceBinding.binding}", service = "${serviceBinding.service}" } ]` : ''}`
|
||||
|
||||
const envPreviewSection = `\n[env.preview]\n${additionalProperties}\n`
|
||||
|
||||
const path = join(location, 'wrangler.toml')
|
||||
let data = readFileSync(path).toString()
|
||||
|
||||
// Replace the entire [env.preview] section including all its properties
|
||||
// Match from [env.preview] to the next section or end of file
|
||||
const previewSectionRegex = /\n\[env\.preview\]\n(?:[^[](?:[^\n]*\n)*)?/
|
||||
|
||||
if (previewSectionRegex.test(data)) {
|
||||
data = data.replace(previewSectionRegex, envPreviewSection)
|
||||
} else {
|
||||
data += envPreviewSection
|
||||
}
|
||||
|
||||
if (queueName) {
|
||||
const envPreviewQueuesSection = `\n[[env.preview.queues.producers]]
|
||||
queue = "${queueName}"
|
||||
binding = "QUEUE"
|
||||
|
||||
[[env.preview.queues.consumers]]
|
||||
queue = "${queueName}"
|
||||
max_retries =10
|
||||
`
|
||||
data += envPreviewQueuesSection
|
||||
}
|
||||
|
||||
writeFileSync(path, data)
|
||||
}
|
||||
183
internal/scripts/lib/didAnyPackageChange.ts
Normal file
183
internal/scripts/lib/didAnyPackageChange.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import assert from 'assert'
|
||||
import { writeFileSync } from 'fs'
|
||||
import * as diff from 'diff'
|
||||
import kleur from 'kleur'
|
||||
import * as tar from 'tar'
|
||||
import tmp from 'tmp'
|
||||
import { exec } from './exec'
|
||||
import { nicelog } from './nicelog'
|
||||
import { PackageDetails, getAllPackageDetails } from './publishing'
|
||||
|
||||
async function getPackageFirstDiff(
|
||||
pkg: PackageDetails,
|
||||
publishedVersion: string
|
||||
): Promise<Diff | null> {
|
||||
assert(process.env.TLDRAW_BEMO_URL, 'TLDRAW_BEMO_URL env var must be set')
|
||||
const dir = tmp.dirSync({ unsafeCleanup: true })
|
||||
const dirPath = dir.name
|
||||
try {
|
||||
const unscopedName = pkg.name.replace('@tldraw/', '')
|
||||
const url = `https://registry.npmjs.org/${pkg.name}/-/${unscopedName}-${publishedVersion}.tgz`
|
||||
const res = await fetch(url)
|
||||
if (res.status === 404) {
|
||||
// The package is not published at this version. This happens when a
|
||||
// new package was added to the monorepo since the last release, or
|
||||
// when a previous publish was interrupted after the version-bump
|
||||
// commit landed but before npm publish completed. In either case
|
||||
// treat it as an added package so the release proceeds instead of
|
||||
// failing the entire workflow.
|
||||
nicelog(
|
||||
`No published tarball for ${pkg.name}@${publishedVersion} (${url}); treating as added.`
|
||||
)
|
||||
return { type: 'added', packageName: pkg.name, filePath: pkg.name }
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
throw new Error(`Failed to fetch ${url}: ${res.status}`)
|
||||
}
|
||||
const publishedTarballPath = `${dirPath}/published-package.tgz`
|
||||
writeFileSync(publishedTarballPath, Buffer.from(await res.arrayBuffer()))
|
||||
const publishedManifest = getTarballManifestSync(publishedTarballPath)
|
||||
|
||||
const localTarballPath = `${dirPath}/local-package.tgz`
|
||||
await exec('yarn', ['pack', '--out', localTarballPath], { pwd: pkg.dir, env: process.env })
|
||||
|
||||
const localManifest = getTarballManifestSync(localTarballPath)
|
||||
|
||||
return getManifestFirstDiff(pkg.name, publishedManifest, localManifest)
|
||||
} finally {
|
||||
dir.removeCallback()
|
||||
}
|
||||
}
|
||||
|
||||
type Diff =
|
||||
| {
|
||||
type: 'added' | 'removed'
|
||||
packageName: string
|
||||
filePath: string
|
||||
}
|
||||
| {
|
||||
type: 'changed'
|
||||
packageName: string
|
||||
filePath: string
|
||||
diff: string
|
||||
}
|
||||
|
||||
// In the `tldraw` package these files are generated during publishing from the
|
||||
// docs content in `apps/docs/content` (see `generate-tldraw-package-docs.ts`),
|
||||
// not from SDK source. A docs-only change therefore alters them, which would
|
||||
// otherwise make this diff check report a package change and cut a spurious SDK
|
||||
// patch release. Ignore them for the `tldraw` package so that docs-only patches
|
||||
// don't trigger a new SDK version. Other packages ship a hand-authored DOCS.md
|
||||
// as real source, so we only skip these for `tldraw`.
|
||||
//
|
||||
// `generate-tldraw-package-docs.ts` writes these files to the package root, so
|
||||
// in the tarball they live at exactly `package/<name>` (tarball entry paths are
|
||||
// prefixed with `package/`). Match the full path rather than the basename so a
|
||||
// same-named file in some other folder still counts as a real change.
|
||||
const GENERATED_DOCS_FILES = new Set(['package/DOCS.md', 'package/RELEASE_NOTES.md'])
|
||||
const GENERATED_DOCS_PACKAGE = 'tldraw'
|
||||
|
||||
function isGeneratedDocsFile(packageName: string, filePath: string) {
|
||||
if (packageName !== GENERATED_DOCS_PACKAGE) return false
|
||||
return GENERATED_DOCS_FILES.has(filePath)
|
||||
}
|
||||
|
||||
function getManifestFirstDiff(
|
||||
packageName: string,
|
||||
a: Record<string, Buffer>,
|
||||
b: Record<string, Buffer>
|
||||
): Diff | null {
|
||||
const aKeys = Object.keys(a).filter((key) => !isGeneratedDocsFile(packageName, key))
|
||||
const bKeys = Object.keys(b).filter((key) => !isGeneratedDocsFile(packageName, key))
|
||||
for (const key of aKeys) {
|
||||
if (!bKeys.includes(key)) {
|
||||
return { type: 'removed', packageName, filePath: key }
|
||||
}
|
||||
if (!a[key].equals(b[key])) {
|
||||
return {
|
||||
type: 'changed',
|
||||
packageName,
|
||||
filePath: key,
|
||||
diff: diff.createTwoFilesPatch(key, key, a[key].toString(), b[key].toString()),
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of bKeys) {
|
||||
if (!aKeys.includes(key)) {
|
||||
return { type: 'added', packageName, filePath: key }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getTarballManifestSync(tarballPath: string) {
|
||||
const manifest: Record<string, Buffer> = {}
|
||||
tar.list({
|
||||
file: tarballPath,
|
||||
onentry: (entry) => {
|
||||
entry.on('data', (data) => {
|
||||
// we could hash these to reduce memory but it's probably fine
|
||||
const existing = manifest[entry.path]
|
||||
if (existing) {
|
||||
manifest[entry.path] = Buffer.concat([existing, data])
|
||||
} else {
|
||||
manifest[entry.path] = data
|
||||
}
|
||||
})
|
||||
},
|
||||
sync: true,
|
||||
})
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
export function formatDiff(diff: Diff) {
|
||||
let message = kleur.cyan().bold(diff.packageName) + ' changed:'
|
||||
switch (diff.type) {
|
||||
case 'added':
|
||||
message += `\n New file added ${kleur.green().bold(diff.filePath)}`
|
||||
break
|
||||
case 'removed':
|
||||
message += `\n File removed ${kleur.red().bold(diff.filePath)}`
|
||||
break
|
||||
case 'changed':
|
||||
message += `\n File changed ${kleur.yellow().bold(diff.filePath)}\n\n`
|
||||
message += diff.diff
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (line.startsWith('+')) {
|
||||
return kleur.green(line)
|
||||
} else if (line.startsWith('-')) {
|
||||
return kleur.red(line)
|
||||
} else {
|
||||
return line
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
break
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare each local package against the published tarball at
|
||||
* `publishedVersion` on npm and return the first detected difference, or null
|
||||
* if every package is identical to its published counterpart.
|
||||
*
|
||||
* Callers should pass the latest version that's actually published on npm
|
||||
* (e.g. from `npm show <pkg> version`) rather than the version in the local
|
||||
* `package.json`. The local version can drift ahead of npm if a previous
|
||||
* publish was interrupted after the version-bump commit was pushed; using the
|
||||
* known-published version keeps the diff check robust to that state.
|
||||
*/
|
||||
export async function getAnyPackageDiff(publishedVersion: string): Promise<Diff | null> {
|
||||
const details = await getAllPackageDetails()
|
||||
for (const pkg of Object.values(details)) {
|
||||
const diff = await getPackageFirstDiff(pkg, publishedVersion)
|
||||
if (diff) {
|
||||
return diff
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
124
internal/scripts/lib/discord.ts
Normal file
124
internal/scripts/lib/discord.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
const MASK_PLACEHOLDER = '\x00MASKED\x00'
|
||||
|
||||
function sanitizeVariables(errorOutput: string, secretValues?: string[]): string {
|
||||
let sanitized = errorOutput
|
||||
|
||||
// Value-based masking: replace any occurrence of known secret values (like GitHub Actions).
|
||||
// Uses an internal placeholder so regex patterns below can't re-match masked output.
|
||||
// Sort longest-first so a longer secret is masked before a shorter substring of it.
|
||||
if (secretValues) {
|
||||
const sorted = secretValues.filter((v) => v.length >= 8).sort((a, b) => b.length - a.length)
|
||||
for (const value of sorted) {
|
||||
sanitized = sanitized.replaceAll(value, MASK_PLACEHOLDER)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize wrangler --var KEY:VALUE patterns
|
||||
sanitized = sanitized.replace(/(--var\s+)(\w+):[^ \n]+/g, '$1$2:`***`')
|
||||
|
||||
// Sanitize KEY=VALUE patterns where KEY looks like an env var and value is long enough
|
||||
// to be a secret (e.g. flyctl secrets set). Short values like =debug are left alone.
|
||||
sanitized = sanitized.replace(/\b([A-Z][A-Z_0-9]{2,})=([^ \n]{8,})/g, '$1=`***`')
|
||||
|
||||
// Sanitize --token VALUE and --token=VALUE patterns (e.g. vercel --token xxx)
|
||||
sanitized = sanitized.replace(/(--token[\s=])\S+/g, '$1`***`')
|
||||
|
||||
// Sanitize connection strings (postgres://, redis://, etc.)
|
||||
sanitized = sanitized.replace(
|
||||
/\b(postgres|postgresql|mysql|redis|mongodb|amqp):\/\/[^\s"']+/gi,
|
||||
'$1://`***`'
|
||||
)
|
||||
|
||||
// Replace internal placeholder with final mask
|
||||
sanitized = sanitized.replaceAll(MASK_PLACEHOLDER, '`***`')
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
export class Discord {
|
||||
static AT_TEAM_MENTION = '<@&1414904550917144586>'
|
||||
|
||||
constructor(opts: {
|
||||
webhookUrl: string
|
||||
shouldNotify: boolean
|
||||
totalSteps?: number
|
||||
messagePrefix?: string
|
||||
secretValues?: string[]
|
||||
}) {
|
||||
this.webhookUrl = opts.webhookUrl
|
||||
this.shouldNotify = opts.shouldNotify
|
||||
this.totalSteps = opts.totalSteps ?? 0
|
||||
this.messagePrefix = opts.messagePrefix ?? ''
|
||||
this.secretValues = opts.secretValues
|
||||
}
|
||||
|
||||
webhookUrl: string
|
||||
shouldNotify: boolean
|
||||
totalSteps: number
|
||||
currentStep = 0
|
||||
messagePrefix: string
|
||||
private secretValues?: string[]
|
||||
|
||||
private async send(method: string, url: string, body: unknown): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(`${this.webhookUrl}${url}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Discord webhook request failed: ${method} -> ${response.status} ${response.statusText}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return response.json()
|
||||
} catch (err) {
|
||||
const errString = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
console.warn(
|
||||
`Discord webhook request failed: ${method}\n${sanitizeVariables(errString, this.secretValues)}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async message(content: string, { always = false }: { always?: boolean } = {}) {
|
||||
if (!always && !this.shouldNotify) {
|
||||
return {
|
||||
edit: () => {
|
||||
// noop
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const prefixedContent = this.messagePrefix ? `${this.messagePrefix} ${content}` : content
|
||||
const message = await this.send('POST', '?wait=true', {
|
||||
content: sanitizeVariables(prefixedContent, this.secretValues),
|
||||
})
|
||||
|
||||
return {
|
||||
edit: async (newContent: string) => {
|
||||
if (!message?.id) return
|
||||
const prefixedNewContent = this.messagePrefix
|
||||
? `${this.messagePrefix} ${newContent}`
|
||||
: newContent
|
||||
await this.send('PATCH', `/messages/${message.id}`, {
|
||||
content: sanitizeVariables(prefixedNewContent, this.secretValues),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async step<T>(content: string, cb: () => Promise<T>): Promise<T> {
|
||||
this.currentStep++
|
||||
const message = await this.message(`[${this.currentStep}/${this.totalSteps}] ${content}...`)
|
||||
try {
|
||||
const result = await cb()
|
||||
await message.edit(`${content} ✅`)
|
||||
return result
|
||||
} catch (err) {
|
||||
await message.edit(`${content} ❌`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/scripts/lib/exec.ts
Normal file
76
internal/scripts/lib/exec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { nicelog } from './nicelog'
|
||||
|
||||
interface ExecOpts {
|
||||
pwd?: string
|
||||
processStdoutLine?(line: string): void
|
||||
processStderrLine?(line: string): void
|
||||
env?: Partial<NodeJS.ProcessEnv>
|
||||
}
|
||||
|
||||
export function prefixOutput(prefix: string) {
|
||||
return {
|
||||
processStdoutLine: (line: string) => process.stdout.write(`${prefix}${line}\n`),
|
||||
processStderrLine: (line: string) => process.stderr.write(`${prefix}${line}\n`),
|
||||
}
|
||||
}
|
||||
|
||||
export async function exec(
|
||||
command: string,
|
||||
args: (string | null)[],
|
||||
{
|
||||
pwd = process.cwd(),
|
||||
processStdoutLine = (line) => process.stdout.write(`${line}\n`),
|
||||
processStderrLine = (line) => process.stderr.write(`${line}\n`),
|
||||
env,
|
||||
}: ExecOpts = {}
|
||||
): Promise<string> {
|
||||
nicelog(`> $ ${command} ${args.join(' ')} (in ${pwd}))`)
|
||||
return new Promise((resolve, reject) => {
|
||||
const data: string[] = []
|
||||
|
||||
const childProcess = execFile(
|
||||
command,
|
||||
args.filter((arg): arg is string => !!arg),
|
||||
{ cwd: pwd, env: { ...process.env, ...env } },
|
||||
(err) => {
|
||||
if (err) reject(err)
|
||||
else resolve(data.join(''))
|
||||
}
|
||||
)
|
||||
|
||||
let pendingStdoutLine = ''
|
||||
childProcess.stdout!.on('data', (chunk) => {
|
||||
const chunkString: string = chunk.toString('utf-8')
|
||||
data.push(chunkString)
|
||||
|
||||
const lines = chunkString.split('\n')
|
||||
lines[0] = pendingStdoutLine + lines[0]
|
||||
pendingStdoutLine = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
processStdoutLine(line)
|
||||
}
|
||||
})
|
||||
childProcess.stdout!.on('close', () => {
|
||||
processStdoutLine(pendingStdoutLine)
|
||||
})
|
||||
|
||||
let pendingStderrLine = ''
|
||||
childProcess.stderr!.on('data', (chunk) => {
|
||||
const chunkString: string = chunk.toString('utf-8')
|
||||
data.push(chunkString)
|
||||
|
||||
const lines = chunkString.split('\n')
|
||||
lines[0] = pendingStderrLine + lines[0]
|
||||
pendingStderrLine = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
processStderrLine(line)
|
||||
}
|
||||
})
|
||||
childProcess.stderr!.on('close', () => {
|
||||
processStderrLine(pendingStderrLine)
|
||||
})
|
||||
})
|
||||
}
|
||||
90
internal/scripts/lib/file.ts
Normal file
90
internal/scripts/lib/file.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { readFile, writeFile as writeFileUnchecked } from 'fs/promises'
|
||||
import { dirname, join, relative } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import json5 from 'json5'
|
||||
import { format } from 'oxfmt'
|
||||
import { nicelog } from './nicelog'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
export const REPO_ROOT = join(__dirname, '../../..')
|
||||
|
||||
const _rootPackageJsonPath = join(REPO_ROOT, 'package.json')
|
||||
if (!existsSync(_rootPackageJsonPath)) {
|
||||
throw new Error('expected to find package.json in REPO_ROOT')
|
||||
}
|
||||
const _rootPackageJson = JSON.parse(readFileSync(_rootPackageJsonPath, 'utf8'))
|
||||
if (_rootPackageJson['name'] !== '@tldraw/monorepo') {
|
||||
throw new Error('expected to find @tldraw/monorepo in REPO_ROOT package.json')
|
||||
}
|
||||
|
||||
export async function readJsonIfExists(file: string) {
|
||||
const fileContents = await readFileIfExists(file)
|
||||
if (fileContents === null) {
|
||||
return null
|
||||
}
|
||||
return json5.parse(fileContents)
|
||||
}
|
||||
|
||||
export async function readFileIfExists(file: string) {
|
||||
try {
|
||||
return await readFile(file, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const oxfmtConfig = json5.parse(readFileSync(join(REPO_ROOT, '.oxfmtrc.json'), 'utf8'))
|
||||
export async function writeCodeFile(
|
||||
generator: string | null,
|
||||
language: 'typescript' | 'javascript',
|
||||
filePath: string,
|
||||
code: string
|
||||
) {
|
||||
const source = generator
|
||||
? `
|
||||
// This file is automatically generated by ${generator}.
|
||||
// Do not edit manually. Or do, I'm a comment, not a cop.
|
||||
|
||||
${code}
|
||||
`
|
||||
: code
|
||||
const ext = language === 'typescript' ? 'ts' : 'js'
|
||||
const result = await format(`file.${ext}`, source, oxfmtConfig)
|
||||
await writeStringFile(filePath, result.code)
|
||||
}
|
||||
|
||||
export async function writeStringFile(filePath: string, contents: string) {
|
||||
await writeFile(filePath, Buffer.from(contents, 'utf-8'))
|
||||
}
|
||||
|
||||
export async function writeFile(filePath: string, contents: Buffer) {
|
||||
if (process.env.CI && !process.env.ALLOW_REFRESH_ASSETS_CHANGES) {
|
||||
let existingContents: Buffer | null = null
|
||||
try {
|
||||
existingContents = await readFile(filePath)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
if (existingContents && !existingContents.equals(contents)) {
|
||||
nicelog(
|
||||
`Asset file ${relative(
|
||||
REPO_ROOT,
|
||||
filePath
|
||||
)} has changed. Please run this script again and commit the changes.`
|
||||
)
|
||||
nicelog('Contents before:')
|
||||
nicelog(existingContents.toString('utf-8'))
|
||||
nicelog('\nContents after:')
|
||||
nicelog(contents.toString('utf-8'))
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
await writeFileUnchecked(filePath, contents, 'utf-8')
|
||||
}
|
||||
|
||||
export async function writeJsonFile(filePath: string, contents: unknown) {
|
||||
await writeStringFile(filePath, JSON.stringify(contents, null, '\t') + '\n')
|
||||
}
|
||||
40
internal/scripts/lib/kill-tree.ts
Normal file
40
internal/scripts/lib/kill-tree.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { spawnSync } from 'child_process'
|
||||
|
||||
/**
|
||||
* Collect every descendant PID of `pid` by walking the process tree with `pgrep -P`.
|
||||
*
|
||||
* Returned breadth-first (direct children first, deepest descendants last). Callers that SIGKILL
|
||||
* the tree should reverse this so they kill deepest-first.
|
||||
*/
|
||||
export function descendantPids(pid: number): number[] {
|
||||
const result = spawnSync('pgrep', ['-P', String(pid)], { encoding: 'utf8' })
|
||||
const childPids = (result.stdout ?? '')
|
||||
.split('\n')
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n > 0)
|
||||
return childPids.flatMap((childPid) => [childPid, ...descendantPids(childPid)])
|
||||
}
|
||||
|
||||
/**
|
||||
* SIGKILL `pid` and its entire descendant tree.
|
||||
*
|
||||
* Our dev orchestrators spawn children through wrapper layers (`yarn run -T tsx …`) and processes
|
||||
* that fork their own grandchildren (vite → esbuild, wrangler → workerd, zero-cache → worker
|
||||
* subprocesses). `child.kill()` only signals the direct child, and SIGKILL is uncatchable and does
|
||||
* not cascade — so killing a wrapper or parent just orphans everything beneath it, leaving processes
|
||||
* that keep holding the fixed dev ports. Walking by PID crosses those wrapper and process-group
|
||||
* boundaries.
|
||||
*
|
||||
* Collect the PIDs up front so the tree does not reparent out from under us while we kill, then
|
||||
* SIGKILL deepest-first. The root `pid` itself is left alone so the caller can finish its own
|
||||
* shutdown and exit. Safe to call from signal and synchronous `exit` handlers.
|
||||
*/
|
||||
export function killProcessTree(pid: number) {
|
||||
for (const target of descendantPids(pid).reverse()) {
|
||||
try {
|
||||
process.kill(target, 'SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
44
internal/scripts/lib/labels.ts
Normal file
44
internal/scripts/lib/labels.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
interface Label {
|
||||
// this is what the label is 'called' on github
|
||||
name: string
|
||||
// this is how we describe the label in our pull request template
|
||||
description: string
|
||||
// this is the section title for the label in our changelogs
|
||||
changelogTitle: string
|
||||
}
|
||||
|
||||
const TYPE_LABELS = [
|
||||
{ name: `bugfix`, description: `Bug fix`, changelogTitle: '🐛 Bug Fixes' },
|
||||
{
|
||||
name: `improvement`,
|
||||
description: `Product improvement`,
|
||||
changelogTitle: '💄 Product Improvements',
|
||||
},
|
||||
{
|
||||
name: `feature`,
|
||||
description: `New feature`,
|
||||
changelogTitle: '🎉 New Features',
|
||||
},
|
||||
{
|
||||
name: `api`,
|
||||
description: `API change`,
|
||||
changelogTitle: '🛠️ API Changes',
|
||||
},
|
||||
{
|
||||
name: `other`,
|
||||
description: `Changes that don't affect SDK users, e.g. internal or .com changes`,
|
||||
changelogTitle: '🤷 Other',
|
||||
},
|
||||
] as const satisfies Label[]
|
||||
|
||||
export function getLabelNames() {
|
||||
return [...TYPE_LABELS].map((label) => label.name)
|
||||
}
|
||||
|
||||
function formatTemplateOption(label: Label) {
|
||||
return `- [ ] \`${label.name}\``
|
||||
}
|
||||
|
||||
export function formatLabelOptionsForPRTemplate() {
|
||||
return TYPE_LABELS.map(formatTemplateOption).join('\n')
|
||||
}
|
||||
18
internal/scripts/lib/makeEnv.ts
Normal file
18
internal/scripts/lib/makeEnv.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function makeEnv<const Keys extends readonly string[]>(
|
||||
keys: Keys
|
||||
): Record<Keys[number], string> {
|
||||
const env = {} as Record<string, string>
|
||||
const missingVars = []
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]
|
||||
if (value === undefined) {
|
||||
missingVars.push(key)
|
||||
continue
|
||||
}
|
||||
env[key] = value
|
||||
}
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(`Missing environment variables: ${missingVars.join(', ')}`)
|
||||
}
|
||||
return env as Record<Keys[number], string>
|
||||
}
|
||||
3
internal/scripts/lib/nicelog.ts
Normal file
3
internal/scripts/lib/nicelog.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function nicelog(...args: any[]) {
|
||||
console.log(...args)
|
||||
}
|
||||
57
internal/scripts/lib/posthog.ts
Normal file
57
internal/scripts/lib/posthog.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { nicelog } from './nicelog'
|
||||
|
||||
/**
|
||||
* The PostHog project we send CI engineering metrics to. `phc_` keys are public, write-only
|
||||
* ingest keys, so keeping this in the repo is fine — it's the same project the e2e performance
|
||||
* reporter writes to. See https://eu.posthog.com/project/45972
|
||||
*/
|
||||
const CI_METRICS_API_KEY = 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6'
|
||||
const DEFAULT_API_HOST = 'https://analytics.tldraw.com/ingest'
|
||||
|
||||
export interface CiMetricEvent {
|
||||
event: string
|
||||
/**
|
||||
* Groups a series together in PostHog. Use a stable string per metric (e.g.
|
||||
* `dotcom-bundle-size`) rather than a per-run id, so a trend over time is one person's events
|
||||
* instead of thousands of one-off people.
|
||||
*/
|
||||
distinctId: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Send events to the CI metrics PostHog project. Everything goes in a single request to the
|
||||
* `/batch/` endpoint: a build reports a few dozen events, and sending them one at a time would
|
||||
* mean a few dozen round trips at the tail of a deploy, where a failure partway through leaves a
|
||||
* build with some of its events recorded and the rest missing.
|
||||
*/
|
||||
export async function captureCiMetricEvents(events: CiMetricEvent[]) {
|
||||
if (events.length === 0) return
|
||||
|
||||
const apiHost = process.env.POSTHOG_API_HOST || DEFAULT_API_HOST
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
const response = await fetch(`${apiHost}/batch/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: CI_METRICS_API_KEY,
|
||||
batch: events.map(({ event, distinctId, properties }) => ({
|
||||
event,
|
||||
properties: {
|
||||
...properties,
|
||||
distinct_id: distinctId,
|
||||
$lib: 'tldraw-ci-metrics',
|
||||
$lib_version: '1.0.0',
|
||||
},
|
||||
timestamp,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PostHog API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
nicelog(`Sent ${events.length} event(s) to PostHog`)
|
||||
}
|
||||
64
internal/scripts/lib/pr-info.ts
Normal file
64
internal/scripts/lib/pr-info.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Octokit } from 'octokit'
|
||||
import { exec } from './exec'
|
||||
import { nicelog } from './nicelog'
|
||||
|
||||
export async function getPrDetailsByNumber(octokit: Octokit, prNumber: number) {
|
||||
return await octokit.rest.pulls
|
||||
.get({
|
||||
owner: 'tldraw',
|
||||
repo: 'tldraw',
|
||||
pull_number: prNumber,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export async function getPrDetails(octokit: Octokit) {
|
||||
const lastCommitMessage = (await exec('git', ['log', '-1', '--oneline'])).trim()
|
||||
const lastPRNumber = lastCommitMessage.match(/\(#(\d+)\)$/)?.[1]
|
||||
if (!lastPRNumber) {
|
||||
nicelog('No PR number found in last commit message. Exiting...')
|
||||
return null
|
||||
}
|
||||
|
||||
return await getPrDetailsByNumber(octokit, parseInt(lastPRNumber))
|
||||
}
|
||||
|
||||
export async function getPrDetailsAndCommitSha(octokit: Octokit) {
|
||||
const prNumber = process.env.PR_NUMBER
|
||||
nicelog(`PR_NUMBER environment variable: ${prNumber}`)
|
||||
let pr: PullRequest | null
|
||||
let commitSha: string | null = null
|
||||
|
||||
if (prNumber) {
|
||||
// Post-merge mode: get PR details by number
|
||||
nicelog(`Post-merge mode: getting PR details for #${prNumber}`)
|
||||
pr = await getPrDetailsByNumber(octokit, parseInt(prNumber))
|
||||
if (!pr) {
|
||||
nicelog('Could not retrieve PR details. Exiting...')
|
||||
return null
|
||||
}
|
||||
commitSha = pr.merge_commit_sha
|
||||
} else {
|
||||
// Merge-time mode: get PR details from commit message
|
||||
nicelog('Merge-time mode: getting PR details from commit message')
|
||||
pr = await getPrDetails(octokit)
|
||||
if (!pr) {
|
||||
nicelog('Could not retrieve PR details. Exiting...')
|
||||
return null
|
||||
}
|
||||
commitSha = (await exec('git', ['rev-parse', 'HEAD'])).trim()
|
||||
}
|
||||
|
||||
if (!commitSha) {
|
||||
nicelog('PR is not merged or merge commit SHA not available. Exiting...')
|
||||
return null
|
||||
}
|
||||
|
||||
return { pr, commitSha }
|
||||
}
|
||||
|
||||
export type PullRequest = NonNullable<Awaited<ReturnType<typeof getPrDetailsByNumber>>>
|
||||
|
||||
export function labelPresent(pr: PullRequest, labelName: string): boolean {
|
||||
return pr.labels.some((label) => label.name === labelName)
|
||||
}
|
||||
295
internal/scripts/lib/publishing.ts
Normal file
295
internal/scripts/lib/publishing.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||
import path, { join } from 'path'
|
||||
import { Octokit } from '@octokit/rest'
|
||||
import { fetch } from 'cross-fetch'
|
||||
import { glob } from 'glob'
|
||||
import { parse } from 'semver'
|
||||
import { exec } from './exec'
|
||||
import { REPO_ROOT } from './file'
|
||||
import { nicelog } from './nicelog'
|
||||
import { getAllWorkspacePackages } from './workspace'
|
||||
|
||||
export interface PackageDetails {
|
||||
name: string
|
||||
dir: string
|
||||
localDeps: string[]
|
||||
version: string
|
||||
}
|
||||
|
||||
async function getPackageDetails(dir: string): Promise<PackageDetails | null> {
|
||||
const packageJsonPath = path.join(dir, 'package.json')
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return null
|
||||
}
|
||||
const packageJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'))
|
||||
if (packageJson.private) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workspacePackages = await getAllWorkspacePackages()
|
||||
return {
|
||||
name: packageJson.name,
|
||||
dir,
|
||||
version: packageJson.version,
|
||||
localDeps: Object.keys(packageJson.dependencies ?? {}).filter((dep) =>
|
||||
workspacePackages.some((p) => p.name === dep)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllPackageDetails(): Promise<Record<string, PackageDetails>> {
|
||||
const dirs = readdirSync(join(REPO_ROOT, 'packages'))
|
||||
const details = await Promise.all(
|
||||
dirs.map((dir) => getPackageDetails(path.join(REPO_ROOT, 'packages', dir)))
|
||||
)
|
||||
const results = details.filter((x): x is PackageDetails => Boolean(x))
|
||||
|
||||
return Object.fromEntries(results.map((result) => [result.name, result]))
|
||||
}
|
||||
|
||||
export async function setAllVersions(version: string, options?: { stageChanges?: boolean }) {
|
||||
const packages = await getAllPackageDetails()
|
||||
for (const packageDetails of Object.values(packages)) {
|
||||
const manifest = JSON.parse(readFileSync(path.join(packageDetails.dir, 'package.json'), 'utf8'))
|
||||
manifest.version = version
|
||||
writeFileSync(
|
||||
path.join(packageDetails.dir, 'package.json'),
|
||||
JSON.stringify(manifest, null, '\t') + '\n'
|
||||
)
|
||||
}
|
||||
|
||||
await exec('yarn', ['refresh-assets', '--force'], { env: { ALLOW_REFRESH_ASSETS_CHANGES: '1' } })
|
||||
|
||||
const lernaJson = JSON.parse(readFileSync('lerna.json', 'utf8'))
|
||||
lernaJson.version = version
|
||||
writeFileSync('lerna.json', JSON.stringify(lernaJson, null, '\t') + '\n')
|
||||
|
||||
execSync('yarn')
|
||||
|
||||
if (options?.stageChanges) {
|
||||
await stageAllPackageJsonChanges()
|
||||
}
|
||||
}
|
||||
|
||||
async function stageAllPackageJsonChanges() {
|
||||
// stage the changes
|
||||
const packageJsonFilesToAdd = []
|
||||
for (const workspace of await getAllWorkspacePackages()) {
|
||||
if (workspace.relativePath.startsWith('packages/')) {
|
||||
packageJsonFilesToAdd.push(`${workspace.relativePath}/package.json`)
|
||||
}
|
||||
}
|
||||
const versionFilesToAdd = glob.sync('**/*/version.ts', {
|
||||
ignore: ['node_modules/**'],
|
||||
follow: false,
|
||||
})
|
||||
console.log('versionFilesToAdd', versionFilesToAdd)
|
||||
await exec('git', [
|
||||
'add',
|
||||
'--update',
|
||||
'lerna.json',
|
||||
...packageJsonFilesToAdd,
|
||||
...versionFilesToAdd,
|
||||
])
|
||||
}
|
||||
|
||||
function assertExists<T>(v: T | null | undefined): T {
|
||||
if (v === null || v === undefined) throw new Error('Expected value to exist')
|
||||
return v
|
||||
}
|
||||
|
||||
export async function getLatestTldrawVersionFromNpm({
|
||||
versionPrefix,
|
||||
}: { versionPrefix?: string } = {}) {
|
||||
if (!versionPrefix)
|
||||
return assertExists(parse((await exec('npm', ['show', 'tldraw', 'version'])).trim()))
|
||||
|
||||
const versions = (await exec('npm', ['show', 'tldraw@~' + versionPrefix, 'version'])).trim()
|
||||
if (versions.startsWith('tldraw')) {
|
||||
return assertExists(parse(versions.split('\n').pop()?.split(' ')[1].replaceAll("'", '')))
|
||||
}
|
||||
return assertExists(parse(versions))
|
||||
}
|
||||
|
||||
function topologicalSortPackages(packages: Record<string, PackageDetails>) {
|
||||
const sorted: PackageDetails[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
function visit(packageName: string, path: string[]) {
|
||||
if (visited.has(packageName)) {
|
||||
return
|
||||
}
|
||||
visited.add(packageName)
|
||||
const packageDetails = packages[packageName]
|
||||
if (!packageDetails) {
|
||||
throw new Error(`Could not find package ${packageName}. path: ${path.join(' -> ')}`)
|
||||
}
|
||||
packageDetails.localDeps.forEach((dep) => visit(dep, [...path, dep]))
|
||||
sorted.push(packageDetails)
|
||||
}
|
||||
|
||||
Object.keys(packages).forEach((packageName) => visit(packageName, [packageName]))
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
export async function publish(distTag?: string) {
|
||||
// Authentication uses npm's trusted publisher OIDC flow. The publish job in
|
||||
// CI must grant `permissions: id-token: write` so yarn (>= 4.10, which we are
|
||||
// on via `packageManager`) can exchange the GitHub-issued OIDC token for a
|
||||
// short-lived publish token automatically.
|
||||
//
|
||||
// We invoke `yarn npm publish` rather than `npm publish` directly so that
|
||||
// yarn rewrites `workspace:*` dependency specifiers in the published
|
||||
// tarball into the concrete sibling versions. `npm publish` has no concept
|
||||
// of yarn's workspace protocol and would ship `"workspace:*"` literally,
|
||||
// breaking installs for any consumer outside this monorepo.
|
||||
// See https://docs.npmjs.com/trusted-publishers
|
||||
const packages = await getAllPackageDetails()
|
||||
|
||||
const publishOrder = topologicalSortPackages(packages)
|
||||
|
||||
// npm can take several minutes before a published version is readable. Waiting only
|
||||
// on a package's own dependencies keeps a newly visible package installable, without
|
||||
// making the run wait for every package's delay in turn.
|
||||
const readable = new Map<string, Promise<void>>()
|
||||
|
||||
for (const packageDetails of publishOrder) {
|
||||
await Promise.all(packageDetails.localDeps.map((dep) => readable.get(dep)))
|
||||
|
||||
const tag = distTag ?? parse(packageDetails.version)?.prerelease[0] ?? 'latest'
|
||||
nicelog(
|
||||
`Publishing ${packageDetails.name} with version ${packageDetails.version} under tag @${tag}`
|
||||
)
|
||||
|
||||
await retry(
|
||||
async () => {
|
||||
let output = ''
|
||||
const publishStart = Date.now()
|
||||
nicelog(
|
||||
`[publish] ${packageDetails.name}@${packageDetails.version} starting npm publish...`
|
||||
)
|
||||
try {
|
||||
await exec(
|
||||
`yarn`,
|
||||
[
|
||||
'npm',
|
||||
'publish',
|
||||
'--tag',
|
||||
String(tag),
|
||||
'--tolerate-republish',
|
||||
'--provenance',
|
||||
'--access',
|
||||
'public',
|
||||
],
|
||||
{
|
||||
pwd: packageDetails.dir,
|
||||
processStdoutLine: (line) => {
|
||||
output += line + '\n'
|
||||
nicelog(line)
|
||||
},
|
||||
processStderrLine: (line) => {
|
||||
output += line + '\n'
|
||||
nicelog(line)
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
// A retry after a publish that actually landed is rejected as "published",
|
||||
// or as "staged" (409) while npm is still processing the first attempt.
|
||||
const lowerOutput = output.toLowerCase()
|
||||
if (
|
||||
lowerOutput.includes('cannot publish over the previously published versions') ||
|
||||
lowerOutput.includes('cannot publish over previously staged version')
|
||||
) {
|
||||
nicelog(
|
||||
`[publish] ${packageDetails.name}@${packageDetails.version} already published or staged, skipping`
|
||||
)
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
const elapsed = ((Date.now() - publishStart) / 1000).toFixed(1)
|
||||
nicelog(
|
||||
`[publish] ${packageDetails.name}@${packageDetails.version} npm publish done (${elapsed}s)`
|
||||
)
|
||||
},
|
||||
{
|
||||
delay: 10_000,
|
||||
numAttempts: 5,
|
||||
}
|
||||
)
|
||||
|
||||
const whenReadable = waitUntilReadable(packageDetails)
|
||||
// Rejections surface when awaited below; without this, one arriving mid-loop would
|
||||
// kill the process in the middle of another package's publish.
|
||||
whenReadable.catch(() => {})
|
||||
readable.set(packageDetails.name, whenReadable)
|
||||
}
|
||||
|
||||
await Promise.all(readable.values())
|
||||
}
|
||||
|
||||
function waitUntilReadable(packageDetails: PackageDetails) {
|
||||
const url = `https://registry.npmjs.org/${packageDetails.name}/${packageDetails.version}`
|
||||
return retry(
|
||||
async ({ attempt, total }) => {
|
||||
const res = await fetch(url, { method: 'HEAD' })
|
||||
if (res.status >= 400) {
|
||||
nicelog(
|
||||
`[verify] ${packageDetails.name}@${packageDetails.version} not readable yet (${res.status}), attempt ${attempt + 1} of ${total}`
|
||||
)
|
||||
throw new Error(`Package not found: ${url} (${res.status})`)
|
||||
}
|
||||
nicelog(`[verify] ${packageDetails.name}@${packageDetails.version} is readable`)
|
||||
},
|
||||
{
|
||||
delay: 10_000,
|
||||
// 15 minutes; the slowest package took over 8 minutes in September 2026.
|
||||
numAttempts: 90,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function retry(
|
||||
fn: (args: { attempt: number; remaining: number; total: number }) => Promise<void>,
|
||||
opts: {
|
||||
numAttempts: number
|
||||
delay: number
|
||||
}
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempts = 0
|
||||
function attempt() {
|
||||
fn({ attempt: attempts, remaining: opts.numAttempts - attempts, total: opts.numAttempts })
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
attempts++
|
||||
if (attempts >= opts.numAttempts) {
|
||||
reject(err)
|
||||
} else {
|
||||
setTimeout(attempt, opts.delay)
|
||||
}
|
||||
})
|
||||
}
|
||||
attempt()
|
||||
})
|
||||
}
|
||||
|
||||
export async function publishProductionDocsAndExamplesAndBemo({
|
||||
gitRef = 'HEAD',
|
||||
}: { gitRef?: string } = {}) {
|
||||
await exec('git', ['push', 'origin', `${gitRef}:docs-production`, `--force`])
|
||||
await exec('git', ['push', 'origin', `${gitRef}:bemo-production`, `--force`])
|
||||
}
|
||||
|
||||
export async function triggerBumpVersionsWorkflow(ghToken: string) {
|
||||
const octokit = new Octokit({ auth: ghToken })
|
||||
await octokit.rest.actions.createWorkflowDispatch({
|
||||
owner: 'tldraw',
|
||||
repo: 'tldraw',
|
||||
workflow_id: 'bump-versions.yml',
|
||||
ref: 'main',
|
||||
})
|
||||
}
|
||||
19
internal/scripts/lib/skip-ci.ts
Normal file
19
internal/scripts/lib/skip-ci.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// GitHub skips both push- and pull_request-triggered workflows when one of these markers appears
|
||||
// in a commit message, in the positions documented at
|
||||
// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
|
||||
// `[^\S\r\n]` rather than `\s`: eating the newlines around a marker on its own body line would
|
||||
// close the blank line separating subject from body, and `git commit --amend -m` would then fold
|
||||
// the whole first paragraph into the subject.
|
||||
const SKIP_CI_MARKER = /[^\S\r\n]*\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]/gi
|
||||
|
||||
/**
|
||||
* Remove GitHub's skip-ci markers from a commit message or PR title.
|
||||
*
|
||||
* Release-notes PRs carry `[skip ci]` in their title on purpose, so their squash-merge onto `main`
|
||||
* doesn't run the push workflows. The hotfix scripts cherry-pick that same commit onto a branch
|
||||
* whose push *must* trigger a workflow (the release branch's publish, or the hotfixes branch's
|
||||
* production build), so the marker has to go before the push.
|
||||
*/
|
||||
export function stripSkipCiMarkers(message: string): string {
|
||||
return message.replace(SKIP_CI_MARKER, '').trim()
|
||||
}
|
||||
42
internal/scripts/lib/sort-unions.ts
Normal file
42
internal/scripts/lib/sort-unions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { namedTypes } from 'ast-types'
|
||||
import { glob } from 'glob'
|
||||
import { parse, print, visit } from 'recast'
|
||||
|
||||
export function sortUnions(tsbuildDir: string) {
|
||||
for (const file of glob.sync(path.join(tsbuildDir, '**/*.d.ts'))) {
|
||||
try {
|
||||
const code = parse(readFileSync(file, 'utf8'), {
|
||||
parser: require('recast/parsers/typescript'),
|
||||
})
|
||||
|
||||
visit(code, {
|
||||
visitTSUnionType(path) {
|
||||
this.traverse(path)
|
||||
const val = path.value as namedTypes.TSUnionType
|
||||
val.types = val.types.sort((a, b) => {
|
||||
const aText = print(a).code
|
||||
const bText = print(b).code
|
||||
return aText.localeCompare(bText)
|
||||
})
|
||||
return false
|
||||
},
|
||||
visitTSTypeLiteral(path) {
|
||||
this.traverse(path)
|
||||
const val = path.value as namedTypes.TSTypeLiteral
|
||||
val.members = val.members.sort((a, b) => {
|
||||
const aText = print(a).code
|
||||
const bText = print(b).code
|
||||
return aText.localeCompare(bText)
|
||||
})
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
writeFileSync(file, print(code).code)
|
||||
} catch (e) {
|
||||
throw new Error(`Error sorting unions in ${file}`, { cause: e })
|
||||
}
|
||||
}
|
||||
}
|
||||
80
internal/scripts/lib/types.ts
Normal file
80
internal/scripts/lib/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { T } from '@tldraw/validate'
|
||||
|
||||
export const EXPORT_CONFIG_KEY = 'tldraw_template' as const
|
||||
|
||||
export const TemplateDeployConfig = T.object({
|
||||
// Where the live demo is hosted. The vercel-hosted demos deploy through vercel's own git
|
||||
// integration, so only cloudflare templates carry a `deploy` config.
|
||||
target: T.literalEnum('cloudflare'),
|
||||
// The hostname the demo is served from. Must match the custom domain route in wrangler.toml.
|
||||
host: T.string,
|
||||
})
|
||||
export type TemplateDeployConfig = T.TypeOf<typeof TemplateDeployConfig>
|
||||
|
||||
export const TemplateConfig = T.object({
|
||||
repo: T.string,
|
||||
cli: T.object({
|
||||
name: T.string,
|
||||
description: T.string,
|
||||
shortDescription: T.string.optional(),
|
||||
order: T.number.optional(),
|
||||
}).optional(),
|
||||
deploy: TemplateDeployConfig.optional(),
|
||||
scripts: T.dict(T.string, T.nullable(T.string)).optional(),
|
||||
})
|
||||
export type TemplateConfig = T.TypeOf<typeof TemplateConfig>
|
||||
|
||||
export const PRODUCT_CONFIG_KEY = 'tldraw_product' as const
|
||||
|
||||
/**
|
||||
* Commercial product metadata for a published package. The `stableId` identifies the commercial
|
||||
* component a package belongs to; it is referenced by legal in order forms and must never change,
|
||||
* even if the package is renamed or moved. Several packages can share one stable id. See
|
||||
* `internal/docs/product-stable-ids.md` for the full convention.
|
||||
*/
|
||||
export const ProductConfig = T.object({
|
||||
// Immutable commercial identifier, e.g. `tldraw:sdk-core`. Never changes on rename/refactor.
|
||||
stableId: T.string,
|
||||
// Customer-facing label, matching the wording used on the order form, e.g. 'Collaboration
|
||||
// module'. Unlike stableId this may be reworded; the id is what contracts reference.
|
||||
name: T.string,
|
||||
// `product` is a top-level order form line item; `feature` is an optional part of its parent
|
||||
// that can be licensed on its own, listed indented beneath it on the order form.
|
||||
type: T.literalEnum('product', 'feature'),
|
||||
// The component this one belongs to, e.g. commenting's parent is the collaboration module.
|
||||
// Components without a parent are top-level order form line items.
|
||||
parent: T.string.optional(),
|
||||
// Whether this component is separately licensed rather than part of the standard SDK offering.
|
||||
premium: T.boolean,
|
||||
// For premium components: the license key FLAGS bit (in packages/editor's LicenseManager) that
|
||||
// entitles a customer to this component.
|
||||
licenseFlag: T.string.optional(),
|
||||
})
|
||||
export type ProductConfig = T.TypeOf<typeof ProductConfig>
|
||||
|
||||
export const PackageJson = T.object({
|
||||
name: T.string,
|
||||
private: T.boolean.optional(),
|
||||
workspaces: T.arrayOf(T.string).optional(),
|
||||
[EXPORT_CONFIG_KEY]: TemplateConfig.optional(),
|
||||
[PRODUCT_CONFIG_KEY]: ProductConfig.optional(),
|
||||
license: T.string.optional(),
|
||||
description: T.string.optional(),
|
||||
scripts: T.dict(T.string, T.nullable(T.string)).optional(),
|
||||
dependencies: T.dict(T.string, T.string).optional(),
|
||||
devDependencies: T.dict(T.string, T.string).optional(),
|
||||
peerDependencies: T.dict(T.string, T.string).optional(),
|
||||
optionalDependencies: T.dict(T.string, T.string).optional(),
|
||||
engines: T.dict(T.string, T.string).optional(),
|
||||
packageManager: T.string.optional(),
|
||||
}).allowUnknownProperties()
|
||||
export type PackageJson = T.TypeOf<typeof PackageJson>
|
||||
|
||||
export const TsConfigJson = T.object({
|
||||
references: T.arrayOf(
|
||||
T.object({
|
||||
path: T.string,
|
||||
})
|
||||
).optional(),
|
||||
}).allowUnknownProperties()
|
||||
export type TsConfigJson = T.TypeOf<typeof TsConfigJson>
|
||||
98
internal/scripts/lib/upload-static-assets.ts
Normal file
98
internal/scripts/lib/upload-static-assets.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import mime from 'mime'
|
||||
import { exec } from './exec'
|
||||
import { makeEnv } from './makeEnv'
|
||||
|
||||
const env = makeEnv(['R2_ACCESS_KEY_ID', 'R2_ACCESS_KEY_SECRET'])
|
||||
|
||||
const R2_URL = 'https://c34edc4e76350954b63adebde86d5eb1.r2.cloudflarestorage.com'
|
||||
const R2_BUCKET = 'cdn'
|
||||
const ASSETS_FOLDER = './packages/assets'
|
||||
|
||||
// How many files to upload to the CDN at once.
|
||||
const UPLOAD_CONCURRENCY = 16
|
||||
|
||||
const R2 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: R2_URL,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
|
||||
},
|
||||
})
|
||||
|
||||
async function uploadFile(key: string, fullPath: string) {
|
||||
const fileStream = fs.createReadStream(fullPath)
|
||||
const contentType = mime.getType(fullPath) ?? 'application/octet-stream'
|
||||
const uploadParams = {
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: fileStream,
|
||||
ContentType: contentType,
|
||||
// these assets will never change, so we can cache them forever:
|
||||
CacheControl: 'public, max-age=31536000, immutable',
|
||||
}
|
||||
await R2.send(new PutObjectCommand(uploadParams))
|
||||
process.stdout.write(` • ${key} ✔️\n`)
|
||||
}
|
||||
|
||||
function collectFiles(
|
||||
prefix: string,
|
||||
directoryPath: string
|
||||
): Array<{ key: string; fullPath: string }> {
|
||||
const files: Array<{ key: string; fullPath: string }> = []
|
||||
|
||||
for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) {
|
||||
const fullPath = path.join(directoryPath, entry.name)
|
||||
const key = path.join(prefix, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectFiles(key, fullPath))
|
||||
} else if (entry.isFile()) {
|
||||
files.push({ key, fullPath })
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
export async function uploadStaticAssets(version: string) {
|
||||
try {
|
||||
await exec('yarn', ['refresh-assets'], { env: { ALLOW_REFRESH_ASSETS_CHANGES: '1' } })
|
||||
|
||||
const entries = fs.readdirSync(ASSETS_FOLDER, { withFileTypes: true })
|
||||
|
||||
// Gather every file across all asset folders up front so we can upload
|
||||
// them with bounded concurrency instead of one at a time.
|
||||
const files: Array<{ key: string; fullPath: string }> = []
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
const folderName = entry.name
|
||||
files.push(...collectFiles(`${version}/${folderName}`, `${ASSETS_FOLDER}/${folderName}`))
|
||||
}
|
||||
|
||||
console.log(`Uploading ${files.length} static assets to CDN...`)
|
||||
|
||||
// Upload files in parallel, keeping at most UPLOAD_CONCURRENCY requests in
|
||||
// flight at a time. Workers pull from a shared queue until it's empty.
|
||||
let next = 0
|
||||
async function worker() {
|
||||
while (next < files.length) {
|
||||
const { key, fullPath } = files[next++]
|
||||
await uploadFile(key, fullPath)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(UPLOAD_CONCURRENCY, files.length) }, () => worker())
|
||||
)
|
||||
|
||||
console.log('Uploaded!')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
27
internal/scripts/lib/worker-externals.ts
Normal file
27
internal/scripts/lib/worker-externals.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Node builtins (and cloudflare modules) that must stay external when bundling a worker
|
||||
* entrypoint with esbuild. With nodejs_compat these resolve at runtime in workerd; esbuild just
|
||||
* needs to be told not to bundle them. Shared between the dev size reporter
|
||||
* (workers/dev.ts) and the CI bundle-size check (check-worker-bundle.ts) so the lists can't drift.
|
||||
*/
|
||||
export const WORKER_EXTERNAL_DEPS = [
|
||||
'cloudflare:*',
|
||||
'crypto',
|
||||
'tls',
|
||||
'net',
|
||||
'stream',
|
||||
'fs',
|
||||
'os',
|
||||
'perf_hooks',
|
||||
'path',
|
||||
'dns',
|
||||
'util',
|
||||
'util/types',
|
||||
'node:child_process',
|
||||
'node:events',
|
||||
'node:path',
|
||||
'node:process',
|
||||
'node:os',
|
||||
'node:timers',
|
||||
'node:util',
|
||||
]
|
||||
62
internal/scripts/lib/workspace.ts
Normal file
62
internal/scripts/lib/workspace.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import path from 'path'
|
||||
import { glob } from 'glob'
|
||||
import { REPO_ROOT, readJsonIfExists } from './file'
|
||||
import { PackageJson } from './types'
|
||||
|
||||
export interface Package {
|
||||
packageJson: PackageJson
|
||||
relativePath: string
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
async function readPackage(packageJsonFile: string): Promise<Package> {
|
||||
const packageJsonPath = path.resolve(packageJsonFile)
|
||||
const packagePath = path.dirname(packageJsonPath)
|
||||
const relativePath = path.relative(REPO_ROOT, packagePath)
|
||||
|
||||
const packageJsonRaw = await readJsonIfExists(packageJsonFile)
|
||||
if (!packageJsonRaw) {
|
||||
throw new Error(`No package.json found at ${packageJsonPath}`)
|
||||
}
|
||||
|
||||
let packageJson: PackageJson
|
||||
try {
|
||||
packageJson = PackageJson.validate(packageJsonRaw)
|
||||
} catch (e: any) {
|
||||
throw new Error(`Invalid package.json in ${relativePath}: ${e.message}`)
|
||||
}
|
||||
|
||||
return {
|
||||
packageJson,
|
||||
relativePath,
|
||||
path: packagePath,
|
||||
name: packageJson.name,
|
||||
}
|
||||
}
|
||||
|
||||
async function getChildWorkspaces(parent: Package): Promise<Package[]> {
|
||||
if (!parent.packageJson.workspaces) return []
|
||||
|
||||
const foundPackages = []
|
||||
for (const workspace of parent.packageJson.workspaces) {
|
||||
const workspacePath = path.join(parent.path, workspace)
|
||||
for (const packageJsonFilePath of glob.sync(path.join(workspacePath, 'package.json'))) {
|
||||
const child = await readPackage(packageJsonFilePath)
|
||||
foundPackages.push(child)
|
||||
if (child.packageJson.workspaces) {
|
||||
foundPackages.push(...(await getChildWorkspaces(child)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return foundPackages
|
||||
}
|
||||
|
||||
export async function getRootPackage() {
|
||||
return await readPackage(path.join(REPO_ROOT, 'package.json'))
|
||||
}
|
||||
|
||||
export async function getAllWorkspacePackages() {
|
||||
return await getChildWorkspaces(await getRootPackage())
|
||||
}
|
||||
Reference in New Issue
Block a user