snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

1
tools/@types/assets/__all.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.css';

View File

@@ -0,0 +1,7 @@
{
"name": "@types/assets",
"private": true,
"types": "./__all.d.ts",
"type": "module",
"version": "0.27.5"
}

41
tools/@types/build-config/__all.d.ts vendored Normal file
View File

@@ -0,0 +1,41 @@
declare interface BUILD_CONFIG_TYPE {
debug: boolean;
distribution: 'web' | 'desktop' | 'admin' | 'mobile' | 'ios' | 'android';
/**
* 'web' | 'desktop' | 'admin'
*/
isDesktopEdition: boolean;
/**
* 'mobile'
*/
isMobileEdition: boolean;
isElectron: boolean;
isWeb: boolean;
/**
* 'desktop' | 'ios' | 'android'
*/
isNative: boolean;
isMobileWeb: boolean;
isIOS: boolean;
isAndroid: boolean;
isAdmin: boolean;
appVersion: string;
editorVersion: string;
appBuildType: 'stable' | 'beta' | 'internal' | 'canary';
githubUrl: string;
changelogUrl: string;
pricingUrl: string;
downloadUrl: string;
discordUrl: string;
requestLicenseUrl: string;
// see: tools/workers
imageProxyUrl: string;
linkPreviewUrl: string;
SENTRY_DSN: string;
}
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;

View File

@@ -0,0 +1,7 @@
{
"name": "@types/build-config",
"private": true,
"types": "./__all.d.ts",
"type": "module",
"version": "0.27.5"
}

52
tools/@types/env/__all.d.ts vendored Normal file
View File

@@ -0,0 +1,52 @@
import '@affine/env/constant';
import '@blocksuite/affine/global/types'
declare module '@blocksuite/affine/store' {
interface DocMeta {
/**
* @deprecated
*/
favorite?: boolean;
trash?: boolean;
trashDate?: number;
updatedDate?: number;
mode?: 'page' | 'edgeless';
// todo: support `number` in the future
isPublic?: boolean;
}
}
declare global {
declare type Environment = {
// Variant
isSelfHosted: boolean;
// Device
isLinux: boolean;
isMacOs: boolean;
isIOS: boolean;
isSafari: boolean;
isWindows: boolean;
isFireFox: boolean;
isMobile: boolean;
isChrome: boolean;
isPwa: boolean;
chromeVersion?: number;
// runtime configs
publicPath: string;
subPath: string;
};
var process: {
env: Record<string, string>;
};
var environment: Environment;
var $AFFINE_SETUP: boolean | undefined;
/**
* Inject by https://www.npmjs.com/package/@sentry/webpack-plugin
*/
var SENTRY_RELEASE: { id: string } | undefined;
}

11
tools/@types/env/package.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@types/affine__env",
"private": true,
"types": "./__all.d.ts",
"type": "module",
"dependencies": {
"@affine/env": "workspace:*",
"@blocksuite/affine": "workspace:*"
},
"version": "0.27.5"
}

183
tools/changelog/index.js Normal file
View File

@@ -0,0 +1,183 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Repository, Sort } from '@napi-rs/simple-git';
import { WebClient } from '@slack/web-api';
import {
generateMarkdown,
parseCommits,
resolveAuthors,
resolveConfig,
} from 'changelogithub';
import { render } from './markdown.js';
const {
DEPLOYED_URL,
NAMESPACE,
CHANNEL_ID,
SLACK_BOT_TOKEN,
PREV_VERSION,
DEPLOYMENT,
FLAVOR,
BLOCKSUITE_REPO_PATH,
} = process.env;
const slack = new WebClient(SLACK_BOT_TOKEN);
const rootDir = join(fileURLToPath(import.meta.url), '..', '..', '..');
const repo = new Repository(rootDir);
/**
* @param {import('@napi-rs/simple-git').Repository} repo
* @param {string} name
*/
function findTagByName(repo, name) {
let tag = null;
repo.tagForeach((id, tagName) => {
if (`refs/tags/v${name}` === tagName.toString('utf-8')) {
tag = repo.findCommit(id);
return false;
}
return true;
});
return tag;
}
/**
* @param {import('@napi-rs/simple-git').Repository} repo
* @param {string} previousCommit
* @param {string | undefined} currentCommit
* @returns {Promise<string>}
*/
async function getChangeLog(repo, previousCommit, currentCommit) {
const prevCommit =
repo.findCommit(previousCommit) ?? findTagByName(repo, previousCommit);
if (!prevCommit) {
console.log(
`Previous commit ${previousCommit} in ${repo.path()} not found`
);
return '';
}
/** @type {typeof import('changelogithub')['parseCommits'] extends (commit: infer C, ...args: any[]) => any ? C : any} */
const commits = [];
const revWalk = repo.revWalk();
let headId = repo.head().target();
if (currentCommit) {
const commit =
repo.findCommit(currentCommit) ?? findTagByName(repo, currentCommit);
if (!commit) {
console.log(
`Current commit ${currentCommit} not found in ${repo.path()}`
);
return '';
}
headId = commit.id();
revWalk.push(commit.id());
} else {
revWalk.pushHead();
}
for (const commitId of revWalk.setSorting(Sort.Time & Sort.Topological)) {
const commit = repo.findCommit(commitId);
commits.push({
message: commit.message(),
body: commit.body() ?? '',
shortHash: commit.id().substring(0, 8),
author: {
name: commit.author().name(),
email: commit.author().email(),
},
});
if (commitId === prevCommit.id()) {
break;
}
}
const parseConfig = await resolveConfig({
token: process.env.GITHUB_TOKEN,
});
parseConfig.from = prevCommit.id();
parseConfig.to = headId;
const parsedCommits = parseCommits(commits, parseConfig);
await resolveAuthors(parsedCommits, parseConfig);
return generateMarkdown(parsedCommits, parseConfig)
.replaceAll('&nbsp;', ' ')
.replaceAll('<samp>', '')
.replaceAll('</samp>', '');
}
let blockSuiteChangelog = '';
const pkgJsonPath = 'packages/frontend/core/package.json';
const content = await readFile(join(rootDir, pkgJsonPath), 'utf8');
const { dependencies } = JSON.parse(content);
const blocksuiteVersion = dependencies['@blocksuite/affine'];
const prevCommit = repo.findCommit(PREV_VERSION);
if (!prevCommit) {
console.info(
`Can't find prev commit ${PREV_VERSION} on the git tree, skip the changelog generation`
);
process.exit(0);
}
const previousPkgJsonBlob = prevCommit
.tree()
.getPath(pkgJsonPath)
.toObject(repo)
.peelToBlob();
const previousPkgJson = JSON.parse(
Buffer.from(previousPkgJsonBlob.content()).toString('utf8')
);
const previousBlocksuiteVersion =
previousPkgJson.dependencies['@blocksuite/affine'];
if (blocksuiteVersion !== previousBlocksuiteVersion) {
const blockSuiteRepo = new Repository(
BLOCKSUITE_REPO_PATH ?? join(rootDir, '..', 'blocksuite')
);
console.log(
`Blocksuite ${previousBlocksuiteVersion} -> ${blocksuiteVersion}`
);
blockSuiteChangelog = await getChangeLog(
blockSuiteRepo,
previousBlocksuiteVersion,
blocksuiteVersion
);
}
const messageHead =
DEPLOYMENT === 'server'
? `# Server deployed in ${NAMESPACE}
- [${DEPLOYED_URL}](${DEPLOYED_URL})
`
: `# AFFiNE Client ${FLAVOR} released`;
let changelogMessage = `${messageHead}
${await getChangeLog(repo, PREV_VERSION)}
`;
if (blockSuiteChangelog) {
changelogMessage += `
# Blocksuite Changelog
${blockSuiteChangelog}`;
}
const { ok } = await slack.chat.postMessage({
channel: CHANNEL_ID,
text: `${DEPLOYMENT === 'server' ? 'Server' : 'Client'} deployed`,
blocks: render(changelogMessage),
});
console.assert(ok, 'Failed to send a message to Slack');

View File

@@ -0,0 +1,25 @@
import { jsxslack } from 'jsx-slack';
import { marked, Renderer } from 'marked';
export const render = markdown => {
const rendered = marked(markdown, {
renderer: new (class CustomRenderer extends Renderer {
heading({ tokens }) {
return `
<Fragment>
<Section><b>${tokens[0].text}</b></Section>
<Divider />
</Fragment>`;
}
list(token) {
return `<Section>${super.list(token)}</Section>`;
}
hr() {
return `<Divider />`;
}
})(),
});
return jsxslack([`<Blocks>${rendered}</Blocks>`]);
};

View File

@@ -0,0 +1,18 @@
{
"name": "@affine/changelog",
"version": "0.27.5",
"type": "module",
"main": "index.js",
"private": true,
"description": "Generate changelog from version changes",
"dependencies": {
"@napi-rs/simple-git": "^1.0.0",
"@slack/web-api": "^8.0.0",
"changelogithub": "^13.0.0",
"jsx-slack": "^6.1.2",
"marked": "^15.0.12"
},
"devDependencies": {
"@types/node": "^22.0.0"
}
}

131
tools/cli/README.md Normal file
View File

@@ -0,0 +1,131 @@
# AFFiNE Monorepo Cli
## Start
```bash
yarn affine -h
```
### Run build command defined in package.json
```bash
yarn affine i18n build
# or
yarn build -p i18n
```
### Run dev command defined in package.json
```bash
yarn affine web dev
# or
yarn dev -p i18n
```
### Clean
```bash
yarn affine clean --dist --rust
# clean node_modules
yarn affine clean --node-modules
```
### Init
> Generate files that make the monorepo work properly, the per project codegen will not be included anymore
```bash
yarn affine init
```
## Tricks
### Define scripts to run a .ts files without manually wiring a TypeScript loader
`affine run` will automatically inject `tsx` for your scripts
```json
{
"name": "@affine/demo",
"scripts": {
"dev": "node ./dev.ts"
}
}
```
```bash
affine @affine/demo dev
```
or
```json
{
"name": "@affine/demo",
"scripts": {
"dev": "r ./src/index.ts"
},
"devDependencies": {
"@affine-tools/cli": "workspace:*"
}
}
```
### Short your key presses
```bash
# af is also available for running the scripts
yarn af web build
```
#### by custom shell script
> personally, I use 'af'
create file `af` in the root of AFFiNE project with the following content
```bash
#!/usr/bin/env sh
./tools/scripts/bin/runner.js affine.ts $@
```
or on windows:
```cmd
node "./tools/cli/bin/runner.js" affine.ts %*
```
and give it executable permission
```bash
chmod a+x ./af
# now you can run scripts with simply
./af web build
```
if you want to go further, but for vscode(or other forks) only, add the following to your `.vscode/settings.json`
```json
{
"terminal.integrated.env.osx": {
"PATH": "${env:PATH}:${cwd}"
},
"terminal.integrated.env.linux": {
"PATH": "${env:PATH}:${cwd}"
},
"terminal.integrated.env.windows": {
"PATH": "${env:PATH};${cwd}"
}
}
```
restart all the integrated terminals and now you get:
```bash
af web build
```
```
```

6
tools/cli/bin/cli.js Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
spawnSync('yarn', ['r', 'affine.ts', ...process.argv.slice(2)], {
stdio: 'inherit',
});

78
tools/cli/bin/runner.js Executable file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const scriptsFolder = join(fileURLToPath(import.meta.url), '..', '..');
const scriptsSrcFolder = join(scriptsFolder, 'src');
const projectRoot = join(scriptsFolder, '..', '..');
const serverRoot = join(projectRoot, 'packages', 'backend', 'server');
const tsRuntimeRegister = join(scriptsFolder, 'register.js');
const tsxRuntimeRegister = join(scriptsFolder, 'tsx-register.js');
const [node, _self, file, ...options] = process.argv;
if (!file) {
console.error(`Please provide a file to run, e.g. 'run src/index.{js/ts}'`);
process.exit(1);
}
const fileLocationCandidates = new Set([
process.cwd(),
scriptsSrcFolder,
projectRoot,
]);
const lookups = [];
/**
* @type {string | undefined}
*/
let scriptLocation;
for (const location of fileLocationCandidates) {
if (scriptLocation) {
break;
}
const fileCandidates = [file, `${file}.js`, `${file}.ts`];
for (const candidate of fileCandidates) {
const candidateLocation = join(location, candidate);
if (existsSync(candidateLocation)) {
scriptLocation = candidateLocation;
break;
}
lookups.push(candidateLocation);
}
}
if (!scriptLocation) {
console.error(
`File ${file} not found, please make sure the first parameter passed to 'run' script is a valid js or ts file.`
);
console.error(`Searched locations: `);
lookups.forEach(location => {
console.error(` - ${location}`);
});
process.exit(1);
}
const nodeOptions = [];
if (
scriptLocation.endsWith('.ts') ||
scriptLocation.startsWith(scriptsFolder)
) {
if (scriptLocation.startsWith(serverRoot)) {
nodeOptions.unshift(`--import=${pathToFileURL(tsRuntimeRegister)}`);
} else {
nodeOptions.unshift(`--import=${pathToFileURL(tsxRuntimeRegister)}`);
}
} else {
nodeOptions.unshift('--experimental-specifier-resolution=node');
}
spawn(node, [...nodeOptions, scriptLocation, ...options], {
stdio: 'inherit',
}).on('exit', code => {
process.exit(code);
});

165
tools/cli/hooks.js Normal file
View File

@@ -0,0 +1,165 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { transform } from '@swc/core';
const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
const JS_EXTENSIONS = ['.js', '.mjs', '.cjs'];
const ALL_EXTENSIONS = [...TS_EXTENSIONS, ...JS_EXTENSIONS];
const JS_EXTENSION_TO_TS = {
'.js': ['.ts', '.tsx', '.js'],
'.mjs': ['.mts', '.mjs'],
'.cjs': ['.cts', '.cjs'],
};
const transformCache = new Map();
function createCandidates(basePath) {
const parsedExt = path.extname(basePath);
const hasKnownExtension =
parsedExt in JS_EXTENSION_TO_TS || ALL_EXTENSIONS.includes(parsedExt);
const ext = hasKnownExtension ? parsedExt : '';
const stem = ext ? basePath.slice(0, -ext.length) : basePath;
const candidates = new Set();
const extensions = ext ? (JS_EXTENSION_TO_TS[ext] ?? [ext]) : ALL_EXTENSIONS;
for (const candidateExt of extensions) {
candidates.add(`${stem}${candidateExt}`);
}
if (!ext) {
for (const candidateExt of ALL_EXTENSIONS) {
candidates.add(path.join(basePath, `index${candidateExt}`));
}
}
return candidates;
}
function isPathLike(specifier) {
return (
specifier.startsWith('./') ||
specifier.startsWith('../') ||
specifier.startsWith('/') ||
specifier.startsWith('file:')
);
}
function resolvePathLikeSpecifier(specifier, parentURL) {
if (!isPathLike(specifier)) {
return undefined;
}
const [specifierWithoutQuery, queryString = ''] = specifier.split('?');
const querySuffix = queryString ? `?${queryString}` : '';
const parentPath = parentURL?.startsWith('file:')
? fileURLToPath(parentURL)
: path.join(process.cwd(), 'index.js');
const basePath = specifierWithoutQuery.startsWith('file:')
? fileURLToPath(specifierWithoutQuery)
: path.isAbsolute(specifierWithoutQuery)
? specifierWithoutQuery
: path.resolve(path.dirname(parentPath), specifierWithoutQuery);
for (const candidate of createCandidates(basePath)) {
try {
if (fs.statSync(candidate).isFile()) {
return `${pathToFileURL(candidate).href}${querySuffix}`;
}
} catch {
// ignore missing candidates
}
}
return undefined;
}
export async function resolve(specifier, context, nextResolve) {
try {
return await nextResolve(specifier, context);
} catch (error) {
const resolvedUrl = resolvePathLikeSpecifier(specifier, context.parentURL);
if (resolvedUrl) {
return {
url: resolvedUrl,
shortCircuit: true,
};
}
throw error;
}
}
export async function load(url, context, nextLoad) {
const [urlWithoutQuery] = url.split('?');
if (!urlWithoutQuery.startsWith('file:')) {
return nextLoad(url, context);
}
const filePath = fileURLToPath(urlWithoutQuery);
if (!TS_EXTENSIONS.has(path.extname(filePath))) {
return nextLoad(url, context);
}
const stat = await fs.promises.stat(filePath);
const cached = transformCache.get(filePath);
if (cached?.mtimeMs === stat.mtimeMs) {
return {
format: cached.format,
source: cached.source,
shortCircuit: true,
};
}
const sourceText = await fs.promises.readFile(filePath, 'utf8');
const isCommonJs = filePath.endsWith('.cts');
const moduleType = isCommonJs ? 'commonjs' : 'es6';
const tsx = filePath.endsWith('.tsx');
let output;
try {
output = await transform(sourceText, {
filename: filePath,
sourceMaps: 'inline',
module: { type: moduleType },
jsc: {
target: 'es2022',
keepClassNames: true,
experimental: { keepImportAttributes: true },
parser: {
syntax: 'typescript',
tsx,
decorators: true,
dynamicImport: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
useDefineForClassFields: false,
react: tsx
? { runtime: 'automatic', importSource: 'react' }
: undefined,
},
},
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`[swc-loader] Failed to compile ${filePath}\n${detail}`);
}
const source = output.code ?? '';
const format = isCommonJs ? 'commonjs' : 'module';
transformCache.set(filePath, { mtimeMs: stat.mtimeMs, source, format });
return {
format,
source,
shortCircuit: true,
};
}

52
tools/cli/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "@affine-tools/cli",
"version": "0.27.5",
"type": "module",
"private": true,
"bin": {
"affine": "./bin/cli.js",
"r": "./bin/runner.js"
},
"exports": {
"./loader": "./loader.js",
"./bundle": "./src/bundle.ts"
},
"scripts": {
"affine": "r ./src/affine.ts"
},
"dependencies": {
"@affine-tools/utils": "workspace:*",
"@affine/s3-compat": "workspace:*",
"@napi-rs/simple-git": "^1.0.0",
"@rspack/core": "^2.0.8",
"@rspack/dev-server": "^2.1.0",
"@sentry/webpack-plugin": "^5.3.0",
"@swc/core": "^1.10.1",
"@tailwindcss/postcss": "^4.3.0",
"@vanilla-extract/webpack-plugin": "^2.3.15",
"autoprefixer": "^10.4.20",
"clipanion": "^3.2.1",
"css-loader": "^7.1.4",
"cssnano": "^7.0.6",
"inquirer": "^12.3.0",
"jsonc-parser": "^3.3.1",
"lodash-es": "^4.17.23",
"mime-types": "^3.0.0",
"oxfmt": "0.63.0",
"postcss": "^8.5.18",
"postcss-loader": "^8.2.1",
"postcss-selector-parser": "^7.1.0",
"source-map-loader": "^5.0.0",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.6",
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typanion": "^3.14.0",
"typescript": "npm:@typescript/typescript6@6.0.2"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/mime-types": "^3.0.0",
"@types/node": "^22.0.0"
}
}

3
tools/cli/register.js Normal file
View File

@@ -0,0 +1,3 @@
import { register } from 'node:module';
register('./hooks.js', import.meta.url);

34
tools/cli/src/affine.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Workspace } from '@affine-tools/utils/workspace';
import { Cli } from 'clipanion';
import { BuildCommand } from './build';
import { BundleCommand } from './bundle';
import { CertCommand } from './cert';
import { CleanCommand } from './clean';
import type { CliContext } from './context';
import { DevCommand } from './dev';
import { InitCommand } from './init';
import { RunCommand } from './run';
const cli = new Cli<CliContext>({
binaryName: 'affine',
binaryVersion: '0.0.0',
binaryLabel: 'AFFiNE Monorepo Tools',
enableColors: true,
enableCapture: true,
});
cli.register(RunCommand);
cli.register(InitCommand);
cli.register(CleanCommand);
cli.register(BuildCommand);
cli.register(DevCommand);
cli.register(BundleCommand);
cli.register(CertCommand);
await cli.runExit(process.argv.slice(2), {
workspace: new Workspace(),
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
});

17
tools/cli/src/build.ts Normal file
View File

@@ -0,0 +1,17 @@
import { PackageCommand } from './command';
export class BuildCommand extends PackageCommand {
static override paths = [['build'], ['b']];
async execute() {
const args: string[] = [];
if (this.deps) {
args.push('--deps', '--wait-deps');
}
args.push(this.package, 'build');
await this.cli.run(args);
}
}

View File

@@ -0,0 +1,72 @@
import type { Configuration as RspackDevServerConfiguration } from '@rspack/dev-server';
export const RSPACK_SUPPORTED_PACKAGES = [
'@affine/admin',
'@affine/web',
'@affine/mobile',
'@affine/ios',
'@affine/android',
'@affine/electron-renderer',
'@affine/server',
'@affine/reader',
] as const;
const rspackSupportedPackageSet = new Set<string>(RSPACK_SUPPORTED_PACKAGES);
export function isRspackSupportedPackageName(name: string) {
return rspackSupportedPackageSet.has(name);
}
export function assertRspackSupportedPackageName(name: string) {
if (isRspackSupportedPackageName(name)) {
return;
}
throw new Error(
`Rspack bundling currently supports: ${Array.from(RSPACK_SUPPORTED_PACKAGES).join(', ')}. Unsupported package: ${name}.`
);
}
export const DEFAULT_DEV_SERVER_CONFIG: RspackDevServerConfiguration = {
host: '0.0.0.0',
allowedHosts: 'all',
hot: false,
liveReload: true,
compress: !process.env.CI,
setupExitSignals: true,
client: {
overlay: process.env.DISABLE_DEV_OVERLAY === 'true' ? false : undefined,
logging: process.env.CI ? 'none' : 'error',
// see: https://webpack.js.org/configuration/dev-server/#websocketurl
// must be an explicit ws/wss URL because custom protocols (e.g. assets://)
// cannot be used to construct WebSocket endpoints in Electron
webSocketURL: 'ws://0.0.0.0:8080/ws',
},
historyApiFallback: {
rewrites: [
{
from: /.*/,
to: () => {
return process.env.SELF_HOSTED === 'true'
? '/selfhost.html'
: '/index.html';
},
},
],
},
proxy: [
{
context: '/api',
target: 'http://localhost:3010',
},
{
context: '/socket.io',
target: 'http://localhost:3010',
ws: true,
},
{
context: '/graphql',
target: 'http://localhost:3010',
},
],
};

306
tools/cli/src/bundle.ts Normal file
View File

@@ -0,0 +1,306 @@
import { rmSync } from 'node:fs';
import { cpus } from 'node:os';
import { Logger } from '@affine-tools/utils/logger';
import { Package } from '@affine-tools/utils/workspace';
import rspack, { type MultiRspackOptions } from '@rspack/core';
import {
type Configuration as RspackDevServerConfiguration,
RspackDevServer,
} from '@rspack/dev-server';
import { merge } from 'lodash-es';
import {
assertRspackSupportedPackageName,
DEFAULT_DEV_SERVER_CONFIG,
} from './bundle-shared';
import { Option, PackageCommand } from './command';
import {
createHTMLTargetConfig as createRspackHTMLTargetConfig,
createNodeTargetConfig as createRspackNodeTargetConfig,
createWorkerTargetConfig as createRspackWorkerTargetConfig,
} from './rspack';
import {
shouldUploadReleaseAssets,
uploadDistAssetsToS3,
} from './rspack-shared/s3-plugin.js';
type WorkerConfig = { name: string };
type CreateWorkerTargetConfig = (pkg: Package, entry: string) => WorkerConfig;
type BaseWorkerOptions = {
includeMermaidAndTypst?: boolean;
};
function assertRspackSupportedPackage(pkg: Package) {
assertRspackSupportedPackageName(pkg.name);
}
function shouldUploadAssetsForPackage(pkg: Package): boolean {
return (
!!process.env.R2_SECRET_ACCESS_KEY && shouldUploadReleaseAssets(pkg.name)
);
}
async function uploadAssetsForPackage(pkg: Package, logger: Logger) {
if (!shouldUploadAssetsForPackage(pkg)) {
return;
}
logger.info('Uploading dist assets to R2...');
await uploadDistAssetsToS3(pkg.distPath.value);
logger.info('Uploaded dist assets to R2.');
}
function getBaseWorkerConfigs(
pkg: Package,
createWorkerTargetConfig: CreateWorkerTargetConfig,
options: BaseWorkerOptions = {}
) {
const core = new Package('@affine/core');
const includeMermaidAndTypst = options.includeMermaidAndTypst ?? true;
const workerConfigs = [
createWorkerTargetConfig(
pkg,
core.srcPath.join(
'modules/workspace-engine/impls/workspace-profile.worker.ts'
).value
),
createWorkerTargetConfig(
pkg,
core.srcPath.join('modules/pdf/renderer/pdf.worker.ts').value
),
createWorkerTargetConfig(
pkg,
core.srcPath.join(
'blocksuite/view-extensions/turbo-renderer/turbo-painter.worker.ts'
).value
),
];
if (includeMermaidAndTypst) {
workerConfigs.push(
createWorkerTargetConfig(
pkg,
core.srcPath.join('modules/mermaid/renderer/mermaid.worker.ts').value
),
createWorkerTargetConfig(
pkg,
core.srcPath.join('modules/typst/renderer/typst.worker.ts').value
)
);
}
return workerConfigs;
}
function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
assertRspackSupportedPackage(pkg);
switch (pkg.name) {
case '@affine/admin': {
return [
createRspackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value, {
selfhostPublicPath: '/admin/',
}),
] as MultiRspackOptions;
}
case '@affine/web':
case '@affine/mobile': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig
);
workerConfigs.push(
createRspackWorkerTargetConfig(
pkg,
pkg.srcPath.join('nbstore.worker.ts').value
)
);
return [
createRspackHTMLTargetConfig(
pkg,
pkg.srcPath.join('index.tsx').value,
{},
workerConfigs.map(config => config.name)
),
...workerConfigs,
] as MultiRspackOptions;
}
case '@affine/ios':
case '@affine/android': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig,
{ includeMermaidAndTypst: false }
);
workerConfigs.push(
createRspackWorkerTargetConfig(
pkg,
pkg.srcPath.join('nbstore.worker.ts').value
)
);
return [
createRspackHTMLTargetConfig(
pkg,
pkg.srcPath.join('index.tsx').value,
{},
workerConfigs.map(config => config.name)
),
...workerConfigs,
] as MultiRspackOptions;
}
case '@affine/electron-renderer': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig,
{ includeMermaidAndTypst: false }
);
return [
createRspackHTMLTargetConfig(
pkg,
{
index: pkg.srcPath.join('app/index.tsx').value,
shell: pkg.srcPath.join('shell/index.tsx').value,
popup: pkg.srcPath.join('popup/index.tsx').value,
backgroundWorker: pkg.srcPath.join('background-worker/index.ts')
.value,
},
{
additionalEntryForSelfhost: false,
injectGlobalErrorHandler: false,
emitAssetsManifest: false,
},
workerConfigs.map(config => config.name)
),
...workerConfigs,
] as MultiRspackOptions;
}
case '@affine/server': {
return [
createRspackNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value),
] as MultiRspackOptions;
}
case '@affine/reader': {
return [
createRspackNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value, {
outputFilename: 'index.js',
decoratorVersion: '2022-03',
libraryType: 'module',
bundleAllDependencies: true,
forceExternal: ['yjs'],
}),
] as MultiRspackOptions;
}
}
throw new Error(`Unsupported package: ${pkg.name}`);
}
export class BundleCommand extends PackageCommand {
static override paths = [['bundle'], ['pack'], ['bun']];
// bundle is not able to run with deps
override _deps = false;
override waitDeps = false;
dev = Option.Boolean('--dev,-d', false, {
description: 'Run in Development mode',
});
async execute() {
const pkg = this.workspace.getPackage(this.package);
if (this.dev) {
await BundleCommand.dev(pkg);
} else {
await BundleCommand.build(pkg);
}
}
static async build(pkg: Package) {
return BundleCommand.buildWithRspack(pkg);
}
static async dev(
pkg: Package,
devServerConfig?: RspackDevServerConfiguration
) {
return BundleCommand.devWithRspack(pkg, devServerConfig);
}
static async buildWithRspack(pkg: Package) {
process.env.NODE_ENV = 'production';
assertRspackSupportedPackage(pkg);
const logger = new Logger('bundle');
logger.info(`Packing package ${pkg.name} with rspack...`);
logger.info('Cleaning old output...');
rmSync(pkg.distPath.value, { recursive: true, force: true });
const config = getRspackBundleConfigs(pkg);
config.parallelism = cpus().length;
const compiler = rspack(config);
if (!compiler) {
throw new Error('Failed to create rspack compiler');
}
try {
const stats = await new Promise<any>((resolve, reject) => {
compiler.run((error, stats) => {
if (error) {
reject(error);
return;
}
if (!stats) {
reject(new Error('Failed to get rspack stats'));
return;
}
resolve(stats);
});
});
if (stats.hasErrors()) {
console.error(stats.toString('errors-only'));
process.exit(1);
return;
}
console.log(stats.toString('minimal'));
await uploadAssetsForPackage(pkg, logger);
} catch (error) {
console.error(error);
process.exit(1);
return;
}
}
static async devWithRspack(
pkg: Package,
devServerConfig?: RspackDevServerConfiguration
) {
process.env.NODE_ENV = 'development';
assertRspackSupportedPackage(pkg);
const logger = new Logger('bundle');
logger.info(`Starting rspack dev server for ${pkg.name}...`);
const config = getRspackBundleConfigs(pkg);
config.parallelism = cpus().length;
const compiler = rspack(config);
if (!compiler) {
throw new Error('Failed to create rspack compiler');
}
const serverConfig = merge({}, DEFAULT_DEV_SERVER_CONFIG, devServerConfig);
if (devServerConfig?.proxy) {
serverConfig.proxy = devServerConfig.proxy;
}
const devServer = new RspackDevServer(serverConfig, compiler);
await devServer.start();
}
}

124
tools/cli/src/cert.ts Normal file
View File

@@ -0,0 +1,124 @@
import { type Path, ProjectRoot } from '@affine-tools/utils/path';
import { Command, Option } from './command';
const CERT_DIR = ProjectRoot.join('.docker/dev/certs');
const CA_DIR = CERT_DIR.join('ca');
const TEMPLATES_DIR = ProjectRoot.join('.docker/dev/templates');
const NGINX_CONF_DIR = ProjectRoot.join('.docker/dev/nginx/conf.d');
const CA_PEM_PATH = CA_DIR.join('affine-self-signed.pem');
const CA_KEY_PATH = CA_DIR.join('affine-self-signed.key');
const CA_ORG = 'AFFiNE Dev CA Self Signed Org';
const CA_NAME = 'AFFiNE Dev CA Self Signed CN';
export class CertCommand extends Command {
static override paths = [['cert']];
install = Option.Boolean('--install', {
description: 'Install the CA',
});
domain = Option.String('--domain', {
description:
'Generate certificates for given domain. e.g. "affine.localhost"',
});
uninstall = Option.Boolean('--uninstall', {
description: 'Uninstall the CA',
});
async execute() {
if (this.install) {
this.installCa();
} else if (this.uninstall) {
this.uninstallCa(CA_PEM_PATH);
} else if (this.domain) {
this.createCert(this.domain);
}
}
private createCert(domain: string) {
if (!this.checkInstalled(CA_PEM_PATH)) {
this.logger.error(
'CA not installed. Please run `yarn affine cert --install` first.'
);
process.exit(1);
}
const domainDir = CERT_DIR.join(domain);
domainDir.rm({ recursive: true });
domainDir.mkdir();
NGINX_CONF_DIR.mkdir();
const keyPath = domainDir.join('key');
const crtPath = domainDir.join('crt');
const csrPath = domainDir.join('csr');
const confPath = domainDir.join('conf');
const nginxConfPath = NGINX_CONF_DIR.join(`${domain}.conf`);
const configTemp = TEMPLATES_DIR.join('openssl.conf')
.readAsFile()
.toString('utf-8');
const config = configTemp.replaceAll('DEV_DOMAIN', domain);
confPath.writeFile(config);
this.exec(`openssl genrsa -out ${keyPath} 2048`);
this.exec(
`openssl req -new -key ${keyPath} -out ${csrPath} -config ${confPath} -subj "/C=/ST=/O=/localityName=/commonName=${domain}/organizationalUnitName=/emailAddress=${domain}@affine.pro/"`
);
this.exec(
`openssl x509 -req -days 1024 -in ${csrPath} -CA ${CA_PEM_PATH} -CAkey ${CA_KEY_PATH} -CAcreateserial -out ${crtPath} -extensions v3_req -extfile ${confPath}`
);
const nginxConfTemp = TEMPLATES_DIR.join('nginx.conf')
.readAsFile()
.toString('utf-8');
const nginxConf = nginxConfTemp.replaceAll('DEV_DOMAIN', domain);
nginxConfPath.writeFile(nginxConf);
}
private installCa() {
if (this.checkInstalled(CA_PEM_PATH)) {
return;
}
// remove old ca
CA_PEM_PATH.rm();
CA_KEY_PATH.rm();
CA_DIR.mkdir();
this.exec(
`openssl req -new -newkey rsa:2048 -days 1024 -nodes -x509 -subj "/C=/ST=/O=${CA_ORG}/localityName=/commonName=${CA_NAME}/organizationalUnitName=Developers/emailAddress=dev@affine.pro/" -keyout ${CA_KEY_PATH} -out ${CA_PEM_PATH}`
);
this.trustCa(CA_PEM_PATH);
}
private trustCa(pem: Path) {
this.logger.info(`Trusting AFFiNE Dev Self Signed CA`);
this.exec(
`sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${pem}`
);
}
private uninstallCa(pem: Path) {
if (!this.checkInstalled(pem)) {
this.logger.error('CA not installed');
return;
}
this.exec(
`sudo security delete-certificate -c ${CA_NAME} /Library/Keychains/System.keychain`
);
}
private checkInstalled(pem: Path) {
if (!pem.exists()) {
return false;
}
const ret = this.exec(`security verify-cert -c ${pem}`);
return ret.includes('...certificate verification successful.');
}
}

71
tools/cli/src/clean.ts Normal file
View File

@@ -0,0 +1,71 @@
import { rmSync } from 'node:fs';
import { exec } from '@affine-tools/utils/process';
import { Command, Option } from './command';
export class CleanCommand extends Command {
static override paths = [['clean']];
cleanDist = Option.Boolean('--dist', false);
cleanRustTarget = Option.Boolean('--rust', false);
cleanNodeModules = Option.Boolean('--node-modules', false);
all = Option.Boolean('--all,-a', false);
async execute() {
this.logger.info('Cleaning Workspace...');
if (this.all || this.cleanNodeModules) {
this.doCleanNodeModules();
}
if (this.all || this.cleanDist) {
this.doCleanDist();
}
if (this.all || this.cleanRustTarget) {
this.doCleanRust();
}
}
doCleanNodeModules() {
this.logger.info('Cleaning node_modules...');
const rootNodeModules = this.workspace.join('node_modules');
if (rootNodeModules.isDirectory()) {
this.logger.info(`Cleaning ${rootNodeModules}`);
rmSync(rootNodeModules.value, { recursive: true });
}
this.workspace.forEach(pkg => {
const nodeModules = pkg.nodeModulesPath;
if (nodeModules.isDirectory()) {
this.logger.info(`Cleaning ${nodeModules}`);
rmSync(nodeModules.value, { recursive: true });
}
});
this.logger.info('node_modules cleaned');
}
doCleanDist() {
this.logger.info('Cleaning dist...');
this.workspace.forEach(pkg => {
if (pkg.distPath.isDirectory()) {
this.logger.info(`Cleaning ${pkg.distPath}`);
rmSync(pkg.distPath.value, { recursive: true });
}
if (pkg.libPath.isDirectory()) {
this.logger.info(`Cleaning ${pkg.libPath}`);
rmSync(pkg.libPath.value, { recursive: true });
}
});
this.logger.info('dist cleaned');
}
doCleanRust() {
exec('', 'cargo clean');
}
}

142
tools/cli/src/command.ts Normal file
View File

@@ -0,0 +1,142 @@
import { AliasToPackage } from '@affine-tools/utils/distribution';
import { Logger } from '@affine-tools/utils/logger';
import { exec, execAsync, spawn } from '@affine-tools/utils/process';
import { type PackageName, Workspace } from '@affine-tools/utils/workspace';
import { Command as BaseCommand, Option } from 'clipanion';
import inquirer from 'inquirer';
import * as t from 'typanion';
import type { CliContext } from './context';
export abstract class Command extends BaseCommand<CliContext> {
// @ts-expect-error hack: Get the command name
cmd = this.constructor.paths[0][0];
get logger() {
return new Logger(this.cmd);
}
get workspace() {
return this.context.workspace;
}
set workspace(workspace: Workspace) {
this.context.workspace = workspace;
}
exec = exec.bind(null, this.cmd);
execAsync = execAsync.bind(null, this.cmd);
spawn = spawn.bind(null, this.cmd);
}
export abstract class PackageCommand extends Command {
protected availablePackageNameArgs = (
Workspace.PackageNames as string[]
).concat(Array.from(AliasToPackage.keys()));
protected packageNameValidator = t.isOneOf(
this.availablePackageNameArgs.map(k => t.isLiteral(k))
);
protected packageNameOrAlias = Option.String('--package,-p', {
required: true,
validator: this.packageNameValidator,
description: 'The package name or alias to be run with',
});
get package(): PackageName {
const name =
AliasToPackage.get(this.packageNameOrAlias as any) ??
(this.packageNameOrAlias as PackageName);
// check
this.workspace.getPackage(name);
return name;
}
protected _deps = Option.Boolean('--deps', false, {
description:
'Execute the same command in workspace dependencies, if defined.',
});
get deps() {
return this._deps;
}
waitDeps = Option.Boolean('--wait-deps', false, {
description: 'Wait for dependencies to be ready before running the command',
});
}
export abstract class PackagesCommand extends Command {
protected availablePackageNameArgs = (
Workspace.PackageNames as string[]
).concat(Array.from(AliasToPackage.keys()));
protected packageNameValidator = t.isOneOf(
this.availablePackageNameArgs.map(k => t.isLiteral(k))
);
protected packageNamesOrAliases = Option.Array('--package,-p', {
required: true,
validator: t.isArray(this.packageNameValidator),
});
get packages() {
return this.packageNamesOrAliases.map(
name => AliasToPackage.get(name as any) ?? name
);
}
deps = Option.Boolean('--deps', false, {
description:
'Execute the same command in workspace dependencies, if defined.',
});
}
export abstract class PackageSelectorCommand extends Command {
protected availablePackages = Workspace.PackageNames;
protected availablePackageNameArgs = (
Workspace.PackageNames as string[]
).concat(Array.from(AliasToPackage.keys()));
protected packageNameValidator = t.isOneOf(
this.availablePackageNameArgs.map(k => t.isLiteral(k))
);
protected packageNameOrAlias = Option.String('--package,-p', {
validator: this.packageNameValidator,
description: 'The package name or alias to be run with',
});
async getPackage(): Promise<PackageName> {
let name = this.packageNameOrAlias
? (AliasToPackage.get(this.packageNameOrAlias as any) ??
this.packageNameOrAlias)
: undefined;
if (!name) {
const answer = await inquirer.prompt([
{
type: 'list',
name: 'package',
message: 'Which package do you want to dev?',
choices: this.availablePackages.map(name => ({
name,
value: name,
})),
pageSize: 10,
default: '@affine/web',
},
]);
name = answer.package as PackageName;
}
// check
this.workspace.getPackage(name as PackageName);
return name as PackageName;
}
}
export { Option };

6
tools/cli/src/context.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { Workspace } from '@affine-tools/utils/workspace';
import type { BaseContext } from 'clipanion';
export interface CliContext extends BaseContext {
workspace: Workspace;
}

35
tools/cli/src/dev.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { PackageName } from '@affine-tools/utils/workspace';
import { Option, PackageSelectorCommand } from './command';
export class DevCommand extends PackageSelectorCommand {
static override paths = [['dev'], ['d']];
protected override availablePackages: PackageName[] = [
'@affine/web',
'@affine/server',
'@affine/electron',
'@affine/electron-renderer',
'@affine/mobile',
'@affine/ios',
'@affine/android',
'@affine/admin',
];
protected deps = Option.Boolean('--deps', {
description: 'Run dev with dependencies',
});
async execute() {
const name = await this.getPackage();
const args = [];
if (this.deps) {
args.push('--deps');
}
args.push(name, 'dev');
await this.cli.run(args);
}
}

134
tools/cli/src/init.ts Executable file
View File

@@ -0,0 +1,134 @@
import { readFileSync, writeFileSync } from 'node:fs';
import type { Path } from '@affine-tools/utils/path';
import {
type Package,
Workspace,
yarnList,
} from '@affine-tools/utils/workspace';
import { applyEdits, modify } from 'jsonc-parser';
import { format, type FormatConfig } from 'oxfmt';
import { Command } from './command';
export class InitCommand extends Command {
static override paths = [['init'], ['i'], ['codegen']];
async execute() {
this.logger.info('Generating Workspace configs');
await this.generateWorkspaceFiles();
this.logger.info('Workspace configs generated');
}
async generateWorkspaceFiles() {
this.workspace = new Workspace(yarnList());
const filesToGenerate: [Path, (prev: string) => string][] = [
[this.workspace.join('tsconfig.json'), this.genProjectTsConfig],
[
this.workspace
.getPackage('@affine-tools/utils')
.join('src/workspace.gen.ts'),
this.genWorkspaceInfo,
],
[this.workspace.join('.oxlintrc.json'), this.genOxlintConfig],
...this.workspace.packages
.filter(p => p.isTsProject)
.map(
p =>
[
p.join('tsconfig.json'),
this.genPackageTsConfig.bind(this, p),
] satisfies [Path, (prev: string) => string]
),
];
for (const [path, content] of filesToGenerate) {
this.logger.info(`Generating: ${path}`);
const previous = readFileSync(path.value, 'utf-8');
const file = await this.format(content(previous), path.value);
writeFileSync(path.value, file);
}
}
async format(content: string, fileName: string) {
const config = JSON.parse(
readFileSync(this.workspace.join('.oxfmtrc.json').value, 'utf-8')
) as FormatConfig;
const result = await format(fileName, content, config);
if (result.errors.length) {
throw new Error(
result.errors.map(error => error.codeframe ?? error.message).join('\n')
);
}
return result.code;
}
genOxlintConfig = () => {
const json = JSON.parse(
readFileSync(this.workspace.join('.oxlintrc.json').value, 'utf-8')
);
const ignoreList = readFileSync(
this.workspace.join('.prettierignore').value,
'utf-8'
)
.split('\n')
.filter(line => line.trim() && !line.startsWith('#'));
json['ignorePatterns'] = ignoreList;
return JSON.stringify(json, null, 2);
};
genWorkspaceInfo = () => {
const list = yarnList();
const names = list.map(p => p.name);
const content = [
'// Auto generated content',
'// DO NOT MODIFY THIS FILE MANUALLY',
`export const PackageList = ${JSON.stringify(list, null, 2)}`,
'',
`export type PackageName = ${names.map(n => `'${n}'`).join(' | ')}`,
];
return content.join('\n');
};
genProjectTsConfig = (prev: string) => {
return applyEdits(
prev,
modify(
prev,
['references'],
this.workspace.packages
.filter(p => p.isTsProject)
.map(p => ({ path: p.path.relativePath })),
{}
)
);
};
genPackageTsConfig = (pkg: Package, prev: string) => {
// TODO(@forehalo):
// currently electron-api => electron => nbstore => electron-api
// this is a circular dependency, we need to fix it
// basically, the electron app don't need to use nbstore for exposing js bridge apis
if (pkg.name === '@affine/electron-api') {
return prev;
}
return applyEdits(
prev,
modify(
prev,
['references'],
pkg.deps
.filter(p => p.isTsProject)
.map(d => ({ path: pkg.path.relative(d.path.value) })),
{}
)
);
};
}

View File

@@ -0,0 +1,111 @@
import type { AtRule, Container, Node, PluginCreator, Rule } from 'postcss';
import selectorParser from 'postcss-selector-parser';
export interface QueuedashScopeOptions {
scopeClass?: string;
}
function normalizeFilePath(filePath: string) {
return filePath.replaceAll('\\', '/').split('?')[0];
}
function isInKeyframes(rule: Rule) {
let parent: Node | undefined = rule.parent;
while (parent) {
if (parent.type === 'atrule') {
const name = (parent as AtRule).name?.toLowerCase();
if (name && name.endsWith('keyframes')) {
return true;
}
}
parent = parent.parent;
}
return false;
}
const DEFAULT_SCOPE_CLASS = 'affine-queuedash';
export const queuedashScopePostcssPlugin: PluginCreator<
QueuedashScopeOptions
> = (options = {}) => {
const scopeClass = options.scopeClass ?? DEFAULT_SCOPE_CLASS;
const scopeSelector = `:where(.${scopeClass})`;
const scopeAst = selectorParser().astSync(scopeSelector);
const scopeNodes = scopeAst.nodes[0]?.nodes;
if (!scopeNodes) {
throw new Error(
`[queuedashScopePostcssPlugin] Failed to parse scope selector: ${scopeSelector}`
);
}
const scopeProcessor = selectorParser(selectors => {
selectors.each(selector => {
const raw = selector.toString().trim();
if (
raw.startsWith(scopeSelector) ||
raw.startsWith(`.${scopeClass}`) ||
raw.startsWith(`:where(.${scopeClass})`)
) {
return;
}
if (
raw === 'html' ||
raw === 'body' ||
raw === ':host' ||
raw === ':root'
) {
selector.nodes = scopeNodes.map(node => node.clone());
return;
}
const prefixNodes = scopeNodes.map(node => node.clone());
const space = selectorParser.combinator({ value: ' ' });
selector.nodes = [...prefixNodes, space, ...selector.nodes];
});
});
return {
postcssPlugin: 'affine-queuedash-scope',
Once(root, { result }) {
const from =
root.source?.input.file ||
root.source?.input.from ||
result.opts.from ||
'';
const normalized = from ? normalizeFilePath(from) : '';
const isQueuedashVendorCss = normalized.endsWith(
'/@queuedash/ui/dist/styles.css'
);
const queuedashLayers: AtRule[] = [];
root.walkAtRules('layer', atRule => {
if (atRule.params?.trim() === 'queuedash' && atRule.nodes?.length) {
queuedashLayers.push(atRule);
}
});
if (!isQueuedashVendorCss && queuedashLayers.length === 0) {
return;
}
const targets: Container[] =
queuedashLayers.length > 0 ? queuedashLayers : [root];
targets.forEach(container => {
container.walkRules(rule => {
if (!rule.selector || isInKeyframes(rule)) {
return;
}
rule.selector = scopeProcessor.processSync(rule.selector);
});
});
},
};
};
queuedashScopePostcssPlugin.postcss = true;

View File

@@ -0,0 +1,100 @@
function testPackageName(regexp: RegExp): (module: any) => boolean {
return (module: any) =>
module.nameForCondition && regexp.test(module.nameForCondition());
}
// https://hackernoon.com/the-100-correct-way-to-split-your-chunks-with-webpack-f8a9df5b7758
export const productionCacheGroups = {
i18n: {
test: /frontend[\\/]i18n[\\/]/,
name: (module: any) => {
const name = module.resource.match(/[\\/]([^\\/]+)\.json$/)?.[1];
if (name && name !== 'en') {
return `i18n-langs.${name}`;
}
return 'i18n';
},
priority: 200,
enforce: true,
},
asyncVendor: {
test: /[\\/]node_modules[\\/]/,
name(module: any) {
const modulePath =
module?.nameForCondition?.() || module?.context || module?.resource;
if (!modulePath || typeof modulePath !== 'string') {
return 'app-async';
}
// monorepo linked in node_modules, so it's not a npm package
if (!modulePath.includes('node_modules')) {
return `app-async`;
}
const name = modulePath.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)?.[1];
return `npm-async-${name}`;
},
priority: Number.MAX_SAFE_INTEGER,
chunks: 'async' as const,
},
blocksuite: {
name: `npm-blocksuite`,
test: testPackageName(/[\\/]node_modules[\\/](@blocksuite)[\\/]/),
priority: 200,
enforce: true,
},
react: {
name: `npm-react`,
test: testPackageName(
/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/
),
priority: 200,
enforce: true,
},
jotai: {
name: `npm-jotai`,
test: testPackageName(/[\\/]node_modules[\\/](jotai)[\\/]/),
priority: 200,
enforce: true,
},
rxjs: {
name: `npm-rxjs`,
test: testPackageName(/[\\/]node_modules[\\/]rxjs[\\/]/),
priority: 200,
enforce: true,
},
lodash: {
name: `npm-lodash`,
test: testPackageName(/[\\/]node_modules[\\/]lodash[\\/]/),
priority: 200,
enforce: true,
},
emotion: {
name: `npm-emotion`,
test: testPackageName(/[\\/]node_modules[\\/](@emotion)[\\/]/),
priority: 200,
enforce: true,
},
vendor: {
name: 'vendor',
test: /[\\/]node_modules[\\/]/,
priority: 190,
enforce: true,
},
styles: {
name: 'styles',
test: (module: any) =>
module.nameForCondition &&
module.nameForCondition()?.endsWith('.css') &&
!module.type.startsWith('javascript'),
chunks: 'all' as const,
minSize: 1,
minChunks: 1,
reuseExistingChunk: true,
priority: 1000,
enforce: true,
},
};

View File

@@ -0,0 +1,116 @@
(function () {
let errorEl = null;
function showGlobalErrorPage() {
if (errorEl) {
return;
}
errorEl = document.createElement('div');
errorEl.innerHTML = [
'<style>',
'.gue {display:flex;flex-direction:column;align-items:center;justify-content:center;width:380px;}',
'.gue img{width:380px;}',
'.gue div{padding:16px 40px 0 40px;text-align:center;}',
'.gue .p1{color:#141414;line-height:24px;font-weight:500;}',
'.gue .p2{color:#7A7A7A;line-height:22px;}',
'</style>',
'<div class="gue">',
'<img src="https://cdn.affine.pro/error.png" />',
'<div>',
'<p class="p1">Unsupported Environment</p>',
'<p class="p2">',
'It looks like AFFiNE cannot run in this environment.',
"Please ensure you are using a supported browser or update your device's operating system to the latest version.",
'If the issue persists, visit our <a href="https://github.com/toeverything/AFFiNE/issues">support page</a> for further assistance.',
'</p>',
'</div>',
'</div>',
].join('');
errorEl.setAttribute(
'style',
'position:absolute;top:0;left:0;height:100vh;width:100vw;display:flex;flex-direction:column;align-items:center;justify-content:center;background:white;z-index:999;'
);
document.body.append(errorEl);
}
/**
* @param event {PromiseRejectionEvent|ErrorEvent}
*/
function handler(event) {
let error;
if ('error' in event) {
error =
event.error ||
(event.message === 'Script error.'
? new SyntaxError(event.message)
: new Error(event.message));
} else {
error = event.reason;
}
console.error('unhandled unrecoverable error', error);
const shouldCache =
// syntax error
error && error instanceof SyntaxError;
if (!shouldCache) {
return;
}
event.stopImmediatePropagation();
showGlobalErrorPage();
}
function registerGlobalErrorHandler() {
if (typeof document !== 'undefined') {
globalThis.addEventListener('unhandledrejection', handler);
globalThis.addEventListener('error', handler);
return function () {
globalThis.removeEventListener('unhandledrejection', handler);
globalThis.removeEventListener('error', handler);
};
}
return null;
}
function unregisterRegisterGlobalErrorHandler(fn) {
if (typeof fn === 'function') {
const app = document.getElementById('app');
if (app) {
let ob = new MutationObserver(function () {
fn();
ob.disconnect();
ob = null;
});
ob.observe(app, { childList: true });
}
}
}
function ensureBasicEnvironment() {
const globals = [
'Promise',
'Map',
'fetch',
'customElements',
'MutationObserver',
];
// oxlint-disable-next-line typescript/prefer-for-of
for (let i = 0; i < globals.length; i++) {
if (!(globals[i] in globalThis)) {
showGlobalErrorPage();
return;
}
}
}
ensureBasicEnvironment();
const goodtogo = registerGlobalErrorHandler();
unregisterRegisterGlobalErrorHandler(goodtogo);
})();

View File

@@ -0,0 +1,297 @@
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { Path, ProjectRoot } from '@affine-tools/utils/path';
import { Repository } from '@napi-rs/simple-git';
import { HtmlRspackPlugin, type HtmlRspackPluginOptions } from '@rspack/core';
import { once } from 'lodash-es';
type HtmlRspackPluginInstance = InstanceType<typeof HtmlRspackPlugin>;
type PluginLike = {
apply: (compiler: CompilerLike) => void;
};
type CompilerLike = {
webpack?: {
sources?: {
RawSource?: new (source: string) => unknown;
};
};
hooks: {
compilation: {
tap: (name: string, callback: (compilation: any) => void) => void;
};
};
};
function createRawSource(compiler: CompilerLike, source: string) {
const RawSource = compiler.webpack?.sources?.RawSource;
if (!RawSource) {
throw new Error(
'compiler.webpack.sources.RawSource is required for html plugin assets emission'
);
}
return new RawSource(source);
}
export const getPublicPath = (BUILD_CONFIG: BUILD_CONFIG_TYPE) => {
const { BUILD_TYPE } = process.env;
if (typeof process.env.PUBLIC_PATH === 'string') {
return process.env.PUBLIC_PATH;
}
if (
BUILD_CONFIG.debug ||
BUILD_CONFIG.distribution === 'desktop' ||
BUILD_CONFIG.distribution === 'ios' ||
BUILD_CONFIG.distribution === 'android'
) {
return '/';
}
switch (BUILD_TYPE) {
case 'stable':
return 'https://prod.affineassets.com/';
case 'beta':
return 'https://beta.affineassets.com/';
default:
return 'https://dev.affineassets.com/';
}
};
const DESCRIPTION = `There can be more than Notion and Miro. AFFiNE is a next-gen knowledge base that brings planning, sorting and creating all together.`;
const gitShortHash = once(() => {
const { GITHUB_SHA } = process.env;
if (GITHUB_SHA) {
return GITHUB_SHA.substring(0, 9);
}
const repo = new Repository(ProjectRoot.value);
const shortSha = repo.head().target()?.substring(0, 9);
if (shortSha) {
return shortSha;
}
const sha = execSync(`git rev-parse --short HEAD`, {
encoding: 'utf-8',
}).trim();
return sha;
});
const currentDir = Path.dir(import.meta.url);
export interface CreateHTMLPluginConfig {
filename?: string;
template?: string;
copySharedPublicAssets?: boolean;
additionalEntryForSelfhost?: boolean;
selfhostPublicPath?: string;
injectGlobalErrorHandler?: boolean;
emitAssetsManifest?: boolean;
}
function getHTMLPluginOptions(BUILD_CONFIG: BUILD_CONFIG_TYPE) {
const publicPath = getPublicPath(BUILD_CONFIG);
const cdnOrigin = publicPath.startsWith('/')
? undefined
: new URL(publicPath).origin;
const templateParams = {
GIT_SHORT_SHA: gitShortHash(),
DESCRIPTION,
PRECONNECT: cdnOrigin
? `<link rel="preconnect" href="${cdnOrigin}" />`
: '',
VIEWPORT_FIT: BUILD_CONFIG.isMobileEdition ? 'cover' : 'auto',
};
return {
template: currentDir.join('template.html').toString(),
inject: 'body',
minify: false,
templateParameters: templateParams,
chunks: ['app'],
scriptLoading: 'blocking',
} satisfies HtmlRspackPluginOptions;
}
const AssetsManifestPlugin = {
apply(compiler: CompilerLike) {
compiler.hooks.compilation.tap('assets-manifest-plugin', compilation => {
HtmlRspackPlugin.getCompilationHooks(
compilation
).beforeAssetTagGeneration.tap('assets-manifest-plugin', arg => {
if (!compilation.getAsset('assets-manifest.json')) {
compilation.emitAsset(
`assets-manifest.json`,
createRawSource(
compiler,
JSON.stringify(
{
...arg.assets,
js: arg.assets.js.map(file =>
file.substring(arg.assets.publicPath.length)
),
css: arg.assets.css.map(file =>
file.substring(arg.assets.publicPath.length)
),
gitHash: gitShortHash(),
description: DESCRIPTION,
},
null,
2
)
),
{
immutable: false,
}
);
}
return arg;
});
});
},
};
const GlobalErrorHandlerPlugin = {
apply(compiler: CompilerLike) {
const globalErrorHandler = [
'js/global-error-handler.js',
readFileSync(currentDir.join('./error-handler.js').toString(), 'utf-8'),
];
compiler.hooks.compilation.tap(
'global-error-handler-plugin',
compilation => {
HtmlRspackPlugin.getCompilationHooks(
compilation
).beforeAssetTagGeneration.tap('global-error-handler-plugin', arg => {
if (!compilation.getAsset(globalErrorHandler[0])) {
compilation.emitAsset(
globalErrorHandler[0],
createRawSource(compiler, globalErrorHandler[1])
);
arg.assets.js.unshift(
arg.assets.publicPath + globalErrorHandler[0]
);
}
return arg;
});
}
);
},
};
const CorsPlugin = {
apply(compiler: CompilerLike) {
compiler.hooks.compilation.tap('html-js-cors-plugin', compilation => {
HtmlRspackPlugin.getCompilationHooks(compilation).alterAssetTags.tap(
'html-js-cors-plugin',
options => {
if (options.publicPath !== '/') {
options.assetTags.scripts.forEach(script => {
script.attributes.crossorigin = true;
});
options.assetTags.styles.forEach(style => {
style.attributes.crossorigin = true;
});
}
return options;
}
);
});
},
};
export function createHTMLPlugins(
BUILD_CONFIG: BUILD_CONFIG_TYPE,
config: CreateHTMLPluginConfig
): (HtmlRspackPluginInstance | PluginLike)[] {
const publicPath = getPublicPath(BUILD_CONFIG);
const htmlPluginOptions = {
...getHTMLPluginOptions(BUILD_CONFIG),
...(config.template ? { template: config.template } : {}),
};
const selfhostPublicPath = config.selfhostPublicPath ?? '/';
const plugins: (HtmlRspackPluginInstance | PluginLike)[] = [];
plugins.push(
new HtmlRspackPlugin({
...htmlPluginOptions,
chunks: ['index'],
filename: config.filename,
publicPath,
meta: {
'env:publicPath': publicPath,
},
})
);
if (BUILD_CONFIG.isElectron) {
plugins.push(
new HtmlRspackPlugin({
...htmlPluginOptions,
chunks: ['shell'],
filename: 'shell.html',
publicPath,
meta: {
'env:publicPath': publicPath,
},
}),
new HtmlRspackPlugin({
...htmlPluginOptions,
filename: 'popup.html',
chunks: ['popup'],
publicPath,
meta: {
'env:publicPath': publicPath,
},
}),
new HtmlRspackPlugin({
...htmlPluginOptions,
filename: 'background-worker.html',
chunks: ['backgroundWorker'],
publicPath,
meta: {
'env:publicPath': publicPath,
},
})
);
}
if (!BUILD_CONFIG.isElectron) {
plugins.push(CorsPlugin);
}
if (config.emitAssetsManifest) {
plugins.push(AssetsManifestPlugin);
}
if (config.injectGlobalErrorHandler) {
plugins.push(GlobalErrorHandlerPlugin);
}
if (config.additionalEntryForSelfhost) {
plugins.push(
new HtmlRspackPlugin({
...htmlPluginOptions,
chunks: ['index'],
publicPath: selfhostPublicPath,
meta: {
'env:isSelfHosted': 'true',
'env:publicPath': selfhostPublicPath,
},
filename: 'selfhost.html',
templateParameters: {
...htmlPluginOptions.templateParameters,
PRECONNECT: '',
},
})
);
}
return plugins;
}

View File

@@ -0,0 +1,18 @@
import { parse } from 'node:path';
export const raw = true;
/**
* @type {import('webpack').LoaderDefinitionFunction}
*/
export default function loader(content) {
const name = parse(this.resourcePath).base;
this.emitFile(name, content);
return `
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const binding = require('./${name}')
export default binding
`;
}

View File

@@ -0,0 +1,141 @@
import { readdir, readFile } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import { createS3CompatClient } from '@affine/s3-compat';
import { lookup } from 'mime-types';
export const R2_BUCKET =
process.env.R2_BUCKET ??
(process.env.BUILD_TYPE === 'canary' ? 'assets-dev' : 'assets-prod');
const S3_UPLOAD_PACKAGE_NAMES = new Set([
'@affine/web',
'@affine/mobile',
'@affine/admin',
]);
const MAX_UPLOAD_RETRIES = 3;
const UPLOAD_RETRY_BASE_DELAY_MS = 500;
function createR2Client() {
const { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY } = process.env;
if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) {
throw new Error('Missing R2 credentials for uploading release assets');
}
return createS3CompatClient(
{
region: 'auto',
bucket: R2_BUCKET,
forcePathStyle: true,
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
},
{
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
}
);
}
async function collectFiles(dir: string): Promise<string[]> {
const dirs = [dir];
const files: string[] = [];
while (dirs.length > 0) {
const current = dirs.pop()!;
const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(current, entry.name);
if (entry.isDirectory()) {
dirs.push(fullPath);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
return files;
}
function toAssetKey(outputPath: string, filePath: string): string {
return relative(outputPath, filePath).split(sep).join('/');
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function putObjectWithRetry(
s3: ReturnType<typeof createR2Client>,
asset: string,
assetSource: Buffer,
contentType: string | false | undefined
) {
let retries = 0;
while (true) {
try {
await s3.putObject(asset, assetSource, {
contentType: contentType || undefined,
contentLength: assetSource.byteLength,
});
return;
} catch (error) {
if (retries >= MAX_UPLOAD_RETRIES) {
throw error;
}
retries += 1;
const delay = UPLOAD_RETRY_BASE_DELAY_MS * 2 ** (retries - 1);
const errorMessage =
error instanceof Error ? error.message : String(error);
console.warn(
`[s3-upload] Retry ${retries}/${MAX_UPLOAD_RETRIES} for ${asset}: ${errorMessage}`
);
await sleep(delay);
}
}
}
async function runInParallel<T>(
values: T[],
worker: (value: T) => Promise<void>,
concurrency = 16
) {
if (values.length === 0) {
return;
}
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(concurrency, values.length) },
async () => {
while (true) {
const index = nextIndex++;
if (index >= values.length) {
return;
}
await worker(values[index]!);
}
}
);
await Promise.all(workers);
}
export function shouldUploadReleaseAssets(pkgName: string): boolean {
return S3_UPLOAD_PACKAGE_NAMES.has(pkgName);
}
export async function uploadDistAssetsToS3(outputPath: string) {
const allFiles = await collectFiles(outputPath);
const uploadFiles = allFiles.filter(file => !file.endsWith('.html'));
if (uploadFiles.length === 0) {
return;
}
const s3 = createR2Client();
await runInParallel(uploadFiles, async filePath => {
const asset = toAssetKey(outputPath, filePath);
const assetSource = await readFile(filePath);
const contentType = lookup(asset);
await putObjectWithRetry(s3, asset, assetSource, contentType);
});
}

View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=<%= VIEWPORT_FIT %>"
/>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<title>AFFiNE</title>
<meta name="theme-color" content="#fafafa" />
<%= PRECONNECT %>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" sizes="192x192" href="/favicon-192.png" />
<link rel="shortcut icon" href="/favicon.ico?v=2" />
<meta name="emotion-insertion-point" content="" />
<meta property="description" content="<%= DESCRIPTION %>" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://app.affine.pro/" />
<meta
name="twitter:title"
content="AFFiNE: There can be more than Notion and Miro."
/>
<meta name="twitter:description" content="<%= DESCRIPTION %>" />
<meta name="twitter:site" content="@AffineOfficial" />
<meta name="twitter:image" content="https://affine.pro/og.jpeg" />
<meta
property="og:title"
content="AFFiNE: There can be more than Notion and Miro."
/>
<meta property="og:type" content="website" />
<meta property="og:description" content="<%= DESCRIPTION %>" />
<meta property="og:url" content="https://app.affine.pro/" />
<meta property="og:image" content="https://affine.pro/og.jpeg" />
</head>
<body>
<div id="app" data-version="<%= GIT_SHORT_SHA %>"></div>
</body>
</html>

View File

@@ -0,0 +1,696 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import { getBuildConfig } from '@affine-tools/utils/build-config';
import { Path, ProjectRoot } from '@affine-tools/utils/path';
import { Package } from '@affine-tools/utils/workspace';
import rspack, {
type Configuration as RspackConfiguration,
} from '@rspack/core';
import type { sentryWebpackPlugin as SentryWebpackPluginFactory } from '@sentry/webpack-plugin';
import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin';
import cssnano from 'cssnano';
import { compact, merge } from 'lodash-es';
import { queuedashScopePostcssPlugin } from '../postcss/queuedash-scope.js';
import { productionCacheGroups } from '../rspack-shared/cache-group.js';
import {
type CreateHTMLPluginConfig,
createHTMLPlugins,
} from '../rspack-shared/html-plugin.js';
const require = createRequire(import.meta.url);
const IN_CI = !!process.env.CI;
const hasSentryBuildEnvs = () =>
!!(
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT
);
function createSentryPlugin() {
if (!hasSentryBuildEnvs()) {
return null;
}
try {
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin') as {
sentryWebpackPlugin: typeof SentryWebpackPluginFactory;
};
return sentryWebpackPlugin({
org: process.env.SENTRY_ORG!,
project: process.env.SENTRY_PROJECT!,
authToken: process.env.SENTRY_AUTH_TOKEN!,
});
} catch (error) {
const reason =
error instanceof Error ? error.message : 'unknown load error';
throw new Error(
`Failed to load @sentry/webpack-plugin while SENTRY_* envs are set: ${reason}`
);
}
}
const availableChannels = ['canary', 'beta', 'stable', 'internal'];
function getBuildConfigFromEnv(pkg: Package) {
const channel = process.env.BUILD_TYPE ?? 'canary';
const dev = process.env.NODE_ENV === 'development';
if (!availableChannels.includes(channel)) {
throw new Error(
`BUILD_TYPE must be one of ${availableChannels.join(', ')}, received [${channel}]`
);
}
return getBuildConfig(pkg, {
// @ts-expect-error checked
channel,
mode: dev ? 'development' : 'production',
});
}
export function createHTMLTargetConfig(
pkg: Package,
entry: string | Record<string, string>,
htmlConfig: Partial<CreateHTMLPluginConfig> = {},
deps?: string[]
): RspackConfiguration {
entry = typeof entry === 'string' ? { index: entry } : entry;
const tailwindConfigPath = pkg.join('tailwind.config.js');
const hasTailwind = tailwindConfigPath.exists();
const tailwindPlugin = tailwindConfigPath.exists()
? ['@tailwindcss/postcss', require(tailwindConfigPath.value)]
: ['@tailwindcss/postcss'];
htmlConfig = merge(
{},
{
filename: 'index.html',
additionalEntryForSelfhost: true,
copySharedPublicAssets: true,
injectGlobalErrorHandler: true,
emitAssetsManifest: true,
},
htmlConfig
);
const buildConfig = getBuildConfigFromEnv(pkg);
const codeBlockPreviewBackendFile =
buildConfig.distribution === 'desktop'
? 'platform-backend.desktop.ts'
: buildConfig.distribution === 'ios' ||
buildConfig.distribution === 'android'
? 'platform-backend.mobile.ts'
: 'platform-backend.ts';
const codeBlockPreviewBackendAlias = ProjectRoot.join(
'packages',
'frontend',
'core',
'src',
'modules',
'code-block-preview-renderer',
codeBlockPreviewBackendFile
).value;
console.log(
`Building [${pkg.name}] for [${buildConfig.appBuildType}] channel in [${buildConfig.debug ? 'development' : 'production'}] mode.`
);
console.log(
`Entry points: ${Object.entries(entry)
.map(([name, path]) => `${name}: ${path}`)
.join(', ')}`
);
console.log(`Output path: ${pkg.distPath.value}`);
console.log(`Config: ${JSON.stringify(buildConfig, null, 2)}`);
const config: RspackConfiguration = {
//#region basic bundler config
name: entry['index'],
dependencies: deps,
context: ProjectRoot.value,
experiments: {
asyncWebAssembly: true,
},
entry,
output: {
environment: { module: true, dynamicImport: true },
filename: buildConfig.debug
? 'js/[name].js'
: 'js/[name].[contenthash:8].js',
assetModuleFilename: buildConfig.debug
? '[name].[contenthash:8][ext]'
: 'assets/[name].[contenthash:8][ext][query]',
path: pkg.distPath.value,
clean: false,
globalObject: 'globalThis',
// NOTE: always keep it '/'
publicPath: '/',
},
target: ['web', 'es2022'],
mode: buildConfig.debug ? 'development' : 'production',
devtool: buildConfig.debug ? 'cheap-module-source-map' : 'source-map',
resolve: {
symlinks: true,
extensionAlias: {
'.js': ['.js', '.tsx', '.ts'],
'.mjs': ['.mjs', '.mts'],
},
extensions: ['.js', '.ts', '.tsx'],
alias: {
yjs: ProjectRoot.join('node_modules', 'yjs').value,
lit: ProjectRoot.join('node_modules', 'lit').value,
'@preact/signals-core': ProjectRoot.join(
'node_modules',
'@preact',
'signals-core'
).value,
'@affine/core/modules/code-block-preview-renderer/platform-backend':
codeBlockPreviewBackendAlias,
},
},
//#endregion
//#region module config
module: {
parser: {
javascript: {
// Do not mock Node.js globals
node: false,
requireJs: false,
import: true,
// Treat as missing export as error
strictExportPresence: true,
},
},
//#region rules
rules: [
{ test: /\.m?js?$/, resolve: { fullySpecified: false } },
{
test: /\.js$/,
enforce: 'pre',
include: /@blocksuite/,
use: ['source-map-loader'],
},
{
oneOf: [
{
test: /\.ts$/,
exclude: /node_modules/,
loader: 'swc-loader',
options: {
// https://swc.rs/docs/configuring-swc/
jsc: {
preserveAllComments: true,
parser: {
syntax: 'typescript',
dynamicImport: true,
topLevelAwait: false,
tsx: false,
decorators: true,
},
target: 'es2022',
externalHelpers: false,
transform: {
useDefineForClassFields: false,
decoratorVersion: '2022-03',
},
},
sourceMaps: true,
inlineSourcesContent: true,
},
},
{
test: /\.tsx$/,
exclude: /node_modules/,
loader: 'swc-loader',
options: {
// https://swc.rs/docs/configuring-swc/
jsc: {
preserveAllComments: true,
parser: {
syntax: 'typescript',
dynamicImport: true,
topLevelAwait: false,
tsx: true,
decorators: true,
},
target: 'es2022',
externalHelpers: false,
transform: {
react: { runtime: 'automatic' },
useDefineForClassFields: false,
decoratorVersion: '2022-03',
},
},
sourceMaps: true,
inlineSourcesContent: true,
},
},
{
test: /\.(png|jpg|gif|svg|webp|mp4|zip)$/,
type: 'asset/resource',
},
{ test: /\.(ttf|eot|woff|woff2)$/, type: 'asset/resource' },
{ test: /\.txt$/, type: 'asset/source' },
{ test: /\.inline\.svg$/, type: 'asset/inline' },
{
test: /\.css$/,
use: [
buildConfig.debug
? 'style-loader'
: rspack.CssExtractRspackPlugin.loader,
{
loader: 'css-loader',
options: {
url: true,
sourceMap: false,
modules: false,
import: true,
importLoaders: 1,
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: hasTailwind
? [
tailwindPlugin,
['autoprefixer'],
...(buildConfig.isAdmin
? [queuedashScopePostcssPlugin()]
: []),
]
: [
cssnano({
preset: ['default', { convertValues: false }],
}),
],
},
},
},
],
},
],
},
],
//#endregion
},
//#endregion
//#region plugins
plugins: compact([
!IN_CI && new rspack.ProgressPlugin(),
...createHTMLPlugins(buildConfig, htmlConfig),
new rspack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
...Object.entries(buildConfig).reduce(
(def, [k, v]) => {
def[`BUILD_CONFIG.${k}`] = JSON.stringify(v);
return def;
},
{} as Record<string, string>
),
}),
!buildConfig.debug &&
// todo: support multiple entry points
new rspack.CssExtractRspackPlugin({
filename: `[name].[contenthash:8].css`,
ignoreOrder: true,
}),
new VanillaExtractPlugin(),
!buildConfig.isAdmin &&
htmlConfig.copySharedPublicAssets &&
new rspack.CopyRspackPlugin({
patterns: [
{
// copy the shared public assets into dist
from: new Package('@affine/core').join('public').value,
},
],
}),
createSentryPlugin(),
// sourcemap url like # sourceMappingURL=76-6370cd185962bc89.js.map wont load in electron
// this is because the default file:// protocol will be ignored by Chromium
// so we need to replace the sourceMappingURL to assets:// protocol
// for example:
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
buildConfig.isElectron &&
new rspack.SourceMapDevToolPlugin({
append: (pathData: { filename?: string }) => {
return `\n//# sourceMappingURL=assets://./${pathData.filename ?? ''}.map`;
},
filename: '[file].map',
}),
]),
//#endregion
stats: { errorDetails: true },
//#region optimization
optimization: {
minimize: !buildConfig.debug,
minimizer: [
new rspack.SwcJsMinimizerRspackPlugin({
extractComments: true,
minimizerOptions: {
ecma: 2020,
compress: { unused: true },
mangle: { keep_classnames: true },
},
}),
],
removeEmptyChunks: true,
providedExports: true,
usedExports: true,
sideEffects: true,
runtimeChunk: { name: 'runtime' },
splitChunks: {
chunks: 'all',
minSize: 1,
minChunks: 1,
maxInitialRequests: Number.MAX_SAFE_INTEGER,
maxAsyncRequests: Number.MAX_SAFE_INTEGER,
cacheGroups: {
...productionCacheGroups,
// Rspack tends to pull async node_modules into the initial vendor chunk
// when `vendor` is configured as `chunks: 'all'`.
vendor: {
...productionCacheGroups.vendor,
chunks: 'initial',
},
},
},
},
//#endregion
};
if (buildConfig.debug && !IN_CI) {
config.optimization = {
...config.optimization,
minimize: false,
runtimeChunk: false,
splitChunks: {
maxInitialRequests: Infinity,
chunks: 'all',
cacheGroups: {
defaultVendors: {
test: `[\\/]node_modules[\\/](?!.*vanilla-extract)`,
priority: -10,
reuseExistingChunk: true,
},
default: { minChunks: 2, priority: -20, reuseExistingChunk: true },
styles: {
name: 'styles',
type: 'css/mini-extract',
chunks: 'all',
enforce: true,
},
},
},
};
}
return config;
}
export function createWorkerTargetConfig(
pkg: Package,
entry: string
): Omit<RspackConfiguration, 'name'> & { name: string } {
const workerName = path.basename(entry).replace(/\.worker\.ts$/, '');
const buildConfig = getBuildConfigFromEnv(pkg);
return {
name: entry,
context: ProjectRoot.value,
experiments: {
asyncWebAssembly: true,
},
entry: { [workerName]: entry },
output: {
filename: `js/${workerName}-${buildConfig.appVersion}.worker.js`,
path: pkg.distPath.value,
clean: false,
globalObject: 'globalThis',
// NOTE: always keep it '/'
publicPath: '/',
},
target: ['webworker', 'es2022'],
mode: buildConfig.debug ? 'development' : 'production',
devtool: buildConfig.debug ? 'cheap-module-source-map' : 'source-map',
resolve: {
symlinks: true,
extensionAlias: { '.js': ['.js', '.ts'], '.mjs': ['.mjs', '.mts'] },
extensions: ['.js', '.ts'],
alias: { yjs: ProjectRoot.join('node_modules', 'yjs').value },
},
module: {
parser: {
javascript: {
// Do not mock Node.js globals
node: false,
requireJs: false,
import: true,
// Treat as missing export as error
strictExportPresence: true,
},
},
rules: [
{ test: /\.m?js?$/, resolve: { fullySpecified: false } },
{
test: /\.js$/,
enforce: 'pre',
include: /@blocksuite/,
use: ['source-map-loader'],
},
{
oneOf: [
{
test: /\.ts$/,
exclude: /node_modules/,
loader: 'swc-loader',
options: {
// https://swc.rs/docs/configuring-swc/
jsc: {
preserveAllComments: true,
parser: {
syntax: 'typescript',
dynamicImport: true,
topLevelAwait: false,
tsx: false,
decorators: true,
},
target: 'es2022',
externalHelpers: false,
transform: {
useDefineForClassFields: false,
decoratorVersion: '2022-03',
},
},
sourceMaps: true,
inlineSourcesContent: true,
},
},
],
},
],
},
plugins: compact([
new rspack.DefinePlugin(
Object.entries(buildConfig).reduce(
(def, [k, v]) => {
def[`BUILD_CONFIG.${k}`] = JSON.stringify(v);
return def;
},
{} as Record<string, string>
)
),
new rspack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
createSentryPlugin(),
]),
stats: { errorDetails: true },
optimization: {
minimize: !buildConfig.debug,
minimizer: [
new rspack.SwcJsMinimizerRspackPlugin({
extractComments: true,
minimizerOptions: {
ecma: 2020,
compress: { unused: true },
mangle: { keep_classnames: true },
},
}),
],
removeEmptyChunks: true,
providedExports: true,
usedExports: true,
sideEffects: true,
runtimeChunk: false,
splitChunks: false,
},
performance: { hints: false },
};
}
export function createNodeTargetConfig(
pkg: Package,
entry: string,
options: {
outputFilename?: string;
decoratorVersion?: 'legacy' | '2022-03';
libraryType?: 'module' | 'commonjs2';
bundleAllDependencies?: boolean;
forceExternal?: string[];
} = {}
): Omit<RspackConfiguration, 'name'> & { name: string } {
const dev = process.env.NODE_ENV === 'development';
const useLegacyDecorator = options.decoratorVersion !== '2022-03';
const forceExternal = options.forceExternal ?? [];
return {
name: entry,
context: ProjectRoot.value,
experiments: {
asyncWebAssembly: true,
},
entry: { index: entry },
output: {
filename: options.outputFilename ?? 'main.js',
path: pkg.distPath.value,
clean: true,
globalObject: 'globalThis',
module: pkg.packageJson.type === 'module',
...(options.libraryType
? { library: { type: options.libraryType } }
: {}),
},
target: ['node', 'es2022'],
externals: ((data: any, callback: (err: null, value: boolean) => void) => {
if (
data.request &&
forceExternal.some(
dep => data.request === dep || data.request.startsWith(`${dep}/`)
)
) {
callback(null, true);
} else if (
data.request &&
// import ... from 'module'
/^[a-zA-Z@]/.test(data.request) &&
!options.bundleAllDependencies &&
// not workspace deps
!pkg.deps.some(dep => data.request!.startsWith(dep.name))
) {
callback(null, true);
} else {
callback(null, false);
}
}) as any,
externalsPresets: { node: true },
node: { __dirname: false, __filename: false },
mode: dev ? 'development' : 'production',
devtool: 'source-map',
resolve: {
symlinks: true,
extensionAlias: { '.js': ['.js', '.ts'], '.mjs': ['.mjs', '.mts'] },
extensions: ['.js', '.ts', '.tsx', '.node'],
alias: { yjs: ProjectRoot.join('node_modules', 'yjs').value },
},
module: {
parser: {
javascript: { url: false, importMeta: false, createRequire: false },
},
rules: [
{
test: /\.js$/,
enforce: 'pre',
include: /@blocksuite/,
use: ['source-map-loader'],
},
{
test: /\.node$/,
loader: Path.dir(import.meta.url).join(
'../rspack-shared/node-loader.js'
).value,
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
loader: 'swc-loader',
options: {
// https://swc.rs/docs/configuring-swc/
jsc: {
preserveAllComments: true,
parser: {
syntax: 'typescript',
dynamicImport: true,
topLevelAwait: true,
tsx: true,
decorators: true,
},
target: 'es2022',
externalHelpers: false,
transform: {
...(useLegacyDecorator
? {
legacyDecorator: true,
decoratorMetadata: true,
}
: {
useDefineForClassFields: false,
decoratorVersion: '2022-03',
}),
react: { runtime: 'automatic' },
},
},
sourceMaps: true,
inlineSourcesContent: true,
},
},
],
},
plugins: compact([
new rspack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
new rspack.IgnorePlugin({
checkResource(resource) {
const lazyImports = [
'@nestjs/microservices',
'@nestjs/websockets/socket-module',
'@apollo/subgraph',
'@apollo/gateway',
'@as-integrations/fastify',
'ts-morph',
'class-validator',
'class-transformer',
];
return lazyImports.some(lazyImport =>
resource.startsWith(lazyImport)
);
},
}),
new rspack.DefinePlugin({
'process.env.NODE_ENV': '"production"',
}),
]),
stats: { errorDetails: true },
optimization: {
nodeEnv: false,
minimize: !dev,
minimizer: [
new rspack.SwcJsMinimizerRspackPlugin({
extractComments: true,
minimizerOptions: {
ecma: 2020,
compress: { unused: true },
mangle: { keep_classnames: true },
},
}),
],
},
performance: { hints: false },
ignoreWarnings: [/^(?!CriticalDependenciesWarning$)/],
};
}

238
tools/cli/src/run.ts Normal file
View File

@@ -0,0 +1,238 @@
import { Path } from '@affine-tools/utils/path';
import { execAsync } from '@affine-tools/utils/process';
import type { Package, PackageName } from '@affine-tools/utils/workspace';
import { Option, PackageCommand } from './command';
interface RunScriptOptions {
includeDependencies?: boolean;
waitDependencies?: boolean;
ignoreIfNotFound?: boolean;
}
const currentDir = Path.dir(import.meta.url);
const serverRuntimeLoader = currentDir
.join('../register.js')
.toFileUrl()
.toString();
const tsxRuntimeLoader = currentDir
.join('../tsx-register.js')
.toFileUrl()
.toString();
const ignoreLoaderScripts = [
'vitest',
'vite',
'tsx',
'prisma',
'cap',
'tsc',
'typedoc',
/^r$/,
/electron(?!-)/,
];
export class RunCommand extends PackageCommand {
static override paths = [[], ['run'], ['r']];
static override usage = PackageCommand.Usage({
description: 'AFFiNE Monorepo scripts',
details: `
\`affine web <script>\` Run any script defined in package's package.json
\`affine init\` Generate the required files if there are any package added or removed
\`affine clean\` Clean the output files of ts, cargo, bundler outputs, etc.
\`affine bundle\` Bundle the packages
\`affine build\` A proxy for <-p package>'s \`build\` script
\`affine dev\` A proxy for <-p package>'s \`dev\` script
`,
examples: [
[`See detail of each command`, '$0 -h'],
[
`Run custom 'xxx' script defined in @affine/web's package.json`,
'$0 web xxx',
],
[`Run 'init' for workspace`, '$0 init'],
[`Clean dist of each package`, '$0 clean --dist'],
[`Clean node_modules under each package`, '$0 clean --node-modules'],
[`Clean everything`, '$0 clean --all'],
[`Run 'build' script for @affine/web`, '$0 build -p web'],
[
`Run 'build' script for @affine/web with all deps prebuild before`,
'$0 build -p web --deps',
],
],
});
// we use positional arguments instead of options
protected override packageNameOrAlias: string = Option.String({
required: true,
validator: this.packageNameValidator,
});
args = Option.Proxy({ name: 'args', required: 1 });
async execute() {
await this.run(this.package, this.args, {
includeDependencies: this.deps,
waitDependencies: this.waitDeps,
});
}
async run(name: PackageName, args: string[], opts: RunScriptOptions = {}) {
opts = {
includeDependencies: false,
waitDependencies: true,
ignoreIfNotFound: false,
...opts,
};
const pkg = this.workspace.getPackage(name);
const scriptName = args[0];
const pkgScript = pkg.scripts[scriptName];
if (pkgScript) {
await this.runScript(pkg, scriptName, args.slice(1), opts);
} else {
await this.runCommand(pkg, args);
}
}
async runScript(
pkg: Package,
scriptName: string,
args: string[],
opts: RunScriptOptions = {}
) {
const rawScript = pkg.scripts[scriptName];
if (!rawScript) {
if (opts.ignoreIfNotFound) {
return;
}
throw new Error(`Script ${scriptName} not found in ${pkg.name}`);
}
const rawArgs = [...rawScript.split(' '), ...args];
const { args: extractedArgs, envs } = this.extractEnvs(rawArgs);
args = extractedArgs;
if (opts.includeDependencies) {
const depsRun = Promise.all(
pkg.deps.map(dep => {
return this.runScript(
pkg.workspace.getPackage(dep.name),
scriptName,
[],
{
...opts,
ignoreIfNotFound: true,
}
);
})
);
if (opts.waitDependencies) {
await depsRun;
} else {
depsRun.catch(e => {
this.logger.error(e);
});
}
}
const isAFFiNECommand = args[0] === 'affine';
if (isAFFiNECommand) {
// remove 'affine' from 'affine xxx' command
args.shift();
args.push('-p', pkg.name);
process.env = {
...process.env,
...envs,
};
await this.cli.run(args);
} else {
await this.runCommand(pkg, rawArgs);
}
}
async runCommand(pkg: Package, args: string[]) {
const { args: extractedArgs, envs } = this.extractEnvs(args);
args = extractedArgs;
const bin = args[0] === 'yarn' ? args[1] : args[0];
const loader =
pkg.name === '@affine/server' ? serverRuntimeLoader : tsxRuntimeLoader;
const hasKnownLoader =
process.env.NODE_OPTIONS?.includes('tsx') ||
process.env.NODE_OPTIONS?.includes(tsxRuntimeLoader) ||
process.env.NODE_OPTIONS?.includes(serverRuntimeLoader);
// very simple test for auto ts/mjs scripts
const isLoaderRequired =
!ignoreLoaderScripts.some(ignore => new RegExp(ignore).test(bin)) ||
hasKnownLoader ||
process.env.NODE_OPTIONS?.includes(loader);
let NODE_OPTIONS = process.env.NODE_OPTIONS
? [process.env.NODE_OPTIONS]
: [];
if (isLoaderRequired) {
NODE_OPTIONS.push(`--import=${loader}`);
}
if (args[0] !== 'yarn') {
// add 'yarn' to the command so we can bypass bin execution to it
args.unshift('yarn');
}
await execAsync(pkg.name, args, {
cwd: pkg.path.value,
env: {
...envs,
NODE_OPTIONS: NODE_OPTIONS.join(' '),
},
});
}
private extractEnvs(args: string[]): {
args: string[];
envs: Record<string, string>;
} {
const envs: Record<string, string> = {};
let i = 0;
while (i < args.length) {
const arg = args[i];
if (arg === 'cross-env') {
i++;
continue;
}
const match = arg.match(/^([A-Z_]+)=(.+)$/);
if (match) {
envs[match[1]] = match[2];
i++;
} else {
// not envs any more
break;
}
}
return {
args: args.slice(i),
envs,
};
}
}

13
tools/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../utils" },
{ "path": "../../packages/common/s3-compat" }
]
}

View File

@@ -0,0 +1 @@
import 'tsx';

View File

@@ -0,0 +1,35 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"scope-enum": [
2,
"always",
[
"admin",
"electron",
"server",
"core",
"web",
"mobile",
"ios",
"android",
"mobile-native",
"docs",
"component",
"env",
"graphql",
"hooks",
"i18n",
"native",
"templates",
"debug",
"nbstore",
"infra",
"editor",
"tools",
"y-octo",
"client"
]
]
}
}

View File

@@ -0,0 +1,3 @@
# commitlint
This package defines commitlint and it's dependencies, so that it can be used in a CI pipeline to minimize the installation time.

View File

@@ -0,0 +1,10 @@
{
"name": "@affine/commitlint-config",
"version": "0.27.5",
"private": true,
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"commitlint": "^19.6.0"
}
}

View File

@@ -0,0 +1,19 @@
import { WebClient } from '@slack/web-api';
import { render } from './markdown.js';
const { CHANNEL_ID, SLACK_BOT_TOKEN, COPILOT_RESULT, BRANCH_SHA, BRANCH_NAME } =
process.env;
const { ok } = await new WebClient(SLACK_BOT_TOKEN).chat.postMessage({
channel: CHANNEL_ID,
text: `AFFiNE Copilot Test ${COPILOT_RESULT}`,
blocks: render(
`# AFFiNE Copilot Test ${COPILOT_RESULT}
- [${BRANCH_NAME?.replace('refs/heads/', '') || BRANCH_SHA}](https://github.com/toeverything/AFFiNE/commit/${BRANCH_SHA})
`
),
});
console.assert(ok, 'Failed to send a message to Slack');

View File

@@ -0,0 +1,25 @@
import { jsxslack } from 'jsx-slack';
import { marked, Renderer } from 'marked';
export const render = markdown => {
const rendered = marked(markdown, {
renderer: new (class CustomRenderer extends Renderer {
heading({ tokens }) {
return `
<Fragment>
<Section><b>${tokens[0].text}</b></Section>
<Divider />
</Fragment>`;
}
list(token) {
return `<Section>${super.list(token)}</Section>`;
}
hr() {
return `<Divider />`;
}
})(),
});
return jsxslack([`<Blocks>${rendered}</Blocks>`]);
};

View File

@@ -0,0 +1,16 @@
{
"name": "@affine/copilot-result",
"version": "0.27.5",
"type": "module",
"main": "index.js",
"private": true,
"description": "Send copilot result to slack",
"dependencies": {
"@slack/web-api": "^8.0.0",
"jsx-slack": "^6.1.2",
"marked": "^15.0.12"
},
"devDependencies": {
"@types/node": "^22.0.0"
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "@affine/doc-diff",
"version": "0.27.5",
"private": true,
"type": "module",
"description": "Diff AFFiNE Yjs snapshot docs (rootdoc/folder/favorite)",
"main": "index.ts",
"scripts": {
"diff": "r ./src/index.ts",
"diff:root": "r ./src/index.ts root",
"diff:folder": "r ./src/index.ts folder",
"diff:favorite": "r ./src/index.ts favorite"
},
"dependencies": {
"@affine-tools/cli": "workspace:*",
"yjs": "^13.6.27"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "npm:@typescript/typescript6@6.0.2"
}
}

View File

@@ -0,0 +1,83 @@
import { isEqual, MISSING, type PlainValue } from './plain';
export type KeyedRecord = { [key: string]: PlainValue };
export type FieldChange = {
key: string;
from: PlainValue | typeof MISSING;
to: PlainValue | typeof MISSING;
};
export type RecordChange = {
id: string;
fromRecord: KeyedRecord;
toRecord: KeyedRecord;
fields: FieldChange[];
};
export type KeyedDiff = {
added: { id: string; record: KeyedRecord }[];
removed: { id: string; record: KeyedRecord }[];
changed: RecordChange[];
};
export function diffKeyedRecords(
fromRecords: Map<string, KeyedRecord>,
toRecords: Map<string, KeyedRecord>
): KeyedDiff {
const added: { id: string; record: KeyedRecord }[] = [];
const removed: { id: string; record: KeyedRecord }[] = [];
const changed: RecordChange[] = [];
const fromIds = new Set(fromRecords.keys());
const toIds = new Set(toRecords.keys());
for (const id of Array.from(toIds).sort()) {
if (!fromIds.has(id)) {
const record = toRecords.get(id);
if (record) {
added.push({ id, record });
}
}
}
for (const id of Array.from(fromIds).sort()) {
if (!toIds.has(id)) {
const record = fromRecords.get(id);
if (record) {
removed.push({ id, record });
}
}
}
for (const id of Array.from(fromIds).sort()) {
if (!toIds.has(id)) {
continue;
}
const fromRecord = fromRecords.get(id);
const toRecord = toRecords.get(id);
if (!fromRecord || !toRecord) {
continue;
}
const keys = Array.from(
new Set([...Object.keys(fromRecord), ...Object.keys(toRecord)])
).sort();
const fields: FieldChange[] = [];
for (const key of keys) {
const fromValue = Object.hasOwn(fromRecord, key)
? fromRecord[key]
: MISSING;
const toValue = Object.hasOwn(toRecord, key) ? toRecord[key] : MISSING;
if (!isEqual(fromValue, toValue)) {
fields.push({ key, from: fromValue, to: toValue });
}
}
if (fields.length) {
changed.push({ id, fromRecord, toRecord, fields });
}
}
return { added, removed, changed };
}

143
tools/doc-diff/src/index.ts Normal file
View File

@@ -0,0 +1,143 @@
import path from 'node:path';
import { getDisplayLabel, readYjsDocFromFile } from './io';
import { extractRootDocPagesMeta, printRootDocPairDiff } from './rootdoc';
import {
extractYjsTable,
printFavoritePairDiff,
printFolderPairDiff,
} from './table';
type Mode = 'rootdoc' | 'folder' | 'favorite';
const HELP_TEXT = `
Diff AFFiNE Yjs snapshot docs between multiple binaries.
Usage:
# Root doc: diff meta.pages
r ./tools/doc-diff/index.ts <rootDoc1.bin> <rootDoc2.bin> [...more]
r ./tools/doc-diff/index.ts root <rootDoc1.bin> <rootDoc2.bin> [...more]
# Organize: diff db$...$folder (table doc)
r ./tools/doc-diff/index.ts folder <folderDoc1.bin> <folderDoc2.bin> [...more]
# Favorites: diff userdata$...$favorite (table doc)
r ./tools/doc-diff/index.ts favorite <favoriteDoc1.bin> <favoriteDoc2.bin> [...more]
Notes:
- Every argument after the optional subcommand is treated as a file path (relative or absolute).
- Files must be the same doc type (no mixing).
`.trim();
function fail(message: string): never {
console.error(message);
console.error('');
console.error(HELP_TEXT);
process.exit(1);
}
function parseMode(value: string): Mode | null {
switch (value.toLowerCase()) {
case 'root':
case 'rootdoc':
case 'meta':
return 'rootdoc';
case 'folder':
case 'folders':
return 'folder';
case 'favorite':
case 'favourite':
case 'favorites':
return 'favorite';
default:
return null;
}
}
function parseArgs(argv: string[]) {
if (argv.some(arg => arg === '-h' || arg === '--help')) {
console.log(HELP_TEXT);
process.exit(0);
}
let mode: Mode = 'rootdoc';
let cursor = 0;
const maybeMode = argv[cursor];
if (maybeMode) {
if (maybeMode.startsWith('-')) {
fail(`Unknown argument: ${maybeMode}`);
}
const parsed = parseMode(maybeMode);
if (parsed) {
mode = parsed;
cursor += 1;
}
}
const files = argv.slice(cursor);
for (const file of files) {
if (file.startsWith('-')) {
fail(`Unknown argument: ${file}`);
}
}
if (files.length < 2) {
fail('Please provide at least two snapshot file paths.');
}
return { mode, files };
}
const { mode, files } = parseArgs(process.argv.slice(2));
const resolvedFiles = files.map(f => path.resolve(process.cwd(), f));
const docs = resolvedFiles.map(filePath => {
try {
return readYjsDocFromFile(filePath);
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
return fail(`Failed to read/parse snapshot file "${filePath}": ${details}`);
}
});
switch (mode) {
case 'rootdoc': {
const metas = docs.map(extractRootDocPagesMeta);
for (let i = 0; i < files.length - 1; i += 1) {
printRootDocPairDiff({
fromLabel: getDisplayLabel(files[i]!),
toLabel: getDisplayLabel(files[i + 1]!),
fromMeta: metas[i]!,
toMeta: metas[i + 1]!,
});
}
break;
}
case 'folder': {
const tables = docs.map(doc => extractYjsTable(doc, 'id'));
for (let i = 0; i < files.length - 1; i += 1) {
printFolderPairDiff({
fromLabel: getDisplayLabel(files[i]!),
toLabel: getDisplayLabel(files[i + 1]!),
fromTable: tables[i]!,
toTable: tables[i + 1]!,
});
}
break;
}
case 'favorite': {
const tables = docs.map(doc => extractYjsTable(doc, 'key'));
for (let i = 0; i < files.length - 1; i += 1) {
printFavoritePairDiff({
fromLabel: getDisplayLabel(files[i]!),
toLabel: getDisplayLabel(files[i + 1]!),
fromTable: tables[i]!,
toTable: tables[i + 1]!,
});
}
break;
}
default: {
const unreachableMode: never = mode;
fail(`Unknown mode: ${unreachableMode}`);
}
}

19
tools/doc-diff/src/io.ts Normal file
View File

@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { applyUpdate, Doc } from 'yjs';
export function getDisplayLabel(inputPath: string) {
const resolved = path.resolve(process.cwd(), inputPath);
if (resolved === inputPath) {
return inputPath;
}
return `${inputPath} (${resolved})`;
}
export function readYjsDocFromFile(filePath: string): Doc {
const bin = readFileSync(filePath);
const doc = new Doc();
applyUpdate(doc, new Uint8Array(bin));
return doc;
}

136
tools/doc-diff/src/plain.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Array as YArray, Doc, Map as YMap, Text } from 'yjs';
export type PlainValue =
| null
| boolean
| number
| string
| PlainValue[]
| { [key: string]: PlainValue };
export const MISSING: unique symbol = Symbol('missing');
export function toPlain(value: unknown): PlainValue {
if (value === null) {
return null;
}
switch (typeof value) {
case 'boolean':
case 'number':
case 'string':
return value;
case 'undefined':
return null;
}
if (value instanceof Text) {
return value.toString();
}
if (value instanceof Doc) {
return { __type: 'YDoc', guid: value.guid };
}
if (value instanceof YArray) {
return value.toArray().map(toPlain);
}
if (value instanceof YMap) {
const keys = Array.from(value.keys()).sort();
const obj: Record<string, PlainValue> = {};
for (const key of keys) {
obj[key] = toPlain(value.get(key));
}
return obj;
}
if (value instanceof Uint8Array) {
return Buffer.from(value).toString('base64');
}
if (Array.isArray(value)) {
return value.map(toPlain);
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
const obj: Record<string, PlainValue> = {};
for (const key of keys) {
obj[key] = toPlain(record[key]);
}
return obj;
}
return String(value);
}
function stableComparable(value: unknown): unknown {
if (value === MISSING) {
return { __missing: true };
}
if (value === null || typeof value !== 'object') {
if (value === undefined) {
return { __undefined: true };
}
return value;
}
if (value instanceof Uint8Array) {
return { __uint8array_base64: Buffer.from(value).toString('base64') };
}
if (Array.isArray(value)) {
return value.map(stableComparable);
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
const obj: Record<string, unknown> = {};
for (const key of keys) {
obj[key] = stableComparable(record[key]);
}
return obj;
}
export function isEqual(a: unknown, b: unknown): boolean {
return (
JSON.stringify(stableComparable(a)) === JSON.stringify(stableComparable(b))
);
}
export function truncate(s: string, maxLen = 200) {
if (s.length <= maxLen) {
return s;
}
return `${s.slice(0, Math.max(0, maxLen - 3))}...`;
}
function isLikelyTimestampKey(key: string) {
const lower = key.toLowerCase();
return (
lower.endsWith('date') ||
lower.endsWith('at') ||
lower === 'created' ||
lower === 'updated' ||
lower.includes('timestamp')
);
}
function formatTimestampMillis(ms: number) {
if (!Number.isFinite(ms)) {
return String(ms);
}
const date = new Date(ms);
if (Number.isNaN(date.getTime())) {
return String(ms);
}
return `${ms} (${date.toISOString()})`;
}
export function formatValue(key: string, value: PlainValue | typeof MISSING) {
if (value === MISSING) {
return '<missing>';
}
if (typeof value === 'number' && isLikelyTimestampKey(key)) {
return formatTimestampMillis(value);
}
return truncate(JSON.stringify(value));
}

View File

@@ -0,0 +1,145 @@
import type { Doc } from 'yjs';
import { Array as YArray, Map as YMap } from 'yjs';
import { diffKeyedRecords, type KeyedRecord } from './diff';
import { formatValue, toPlain } from './plain';
type RootDocMetaExtract = {
recordsById: Map<string, KeyedRecord>;
duplicateIds: string[];
};
export function extractRootDocPagesMeta(rootDoc: Doc): RootDocMetaExtract {
const recordsById = new Map<string, KeyedRecord>();
const duplicateIds: string[] = [];
const meta = rootDoc.getMap('meta');
const pages = meta.get('pages');
const entries =
pages instanceof YArray
? pages.toArray()
: Array.isArray(pages)
? pages
: null;
if (!entries) {
return { recordsById, duplicateIds };
}
for (const entry of entries) {
let id: string | null = null;
if (entry instanceof YMap) {
const idRaw = entry.get('id');
id =
typeof idRaw === 'string'
? idRaw
: typeof idRaw?.toString === 'function'
? idRaw.toString()
: null;
} else if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
const record = entry as Record<string, unknown>;
id =
typeof record.id === 'string'
? record.id
: (record.id?.toString?.() ?? null);
}
if (!id) {
continue;
}
if (recordsById.has(id)) {
duplicateIds.push(id);
}
const metaPlain = toPlain(entry) as unknown;
if (
metaPlain &&
typeof metaPlain === 'object' &&
!Array.isArray(metaPlain)
) {
recordsById.set(id, metaPlain as KeyedRecord);
}
}
return { recordsById, duplicateIds };
}
function docLabel(id: string, meta: KeyedRecord) {
const title = meta.title;
if (typeof title === 'string' && title.trim()) {
return `${id} "${title}"`;
}
return id;
}
export function printRootDocPairDiff(opts: {
fromLabel: string;
toLabel: string;
fromMeta: RootDocMetaExtract;
toMeta: RootDocMetaExtract;
}) {
const diff = diffKeyedRecords(
opts.fromMeta.recordsById,
opts.toMeta.recordsById
);
console.log(`\n=== ${opts.fromLabel} -> ${opts.toLabel} ===`);
console.log(
`Docs: ${opts.fromMeta.recordsById.size} -> ${opts.toMeta.recordsById.size} (+${diff.added.length} / -${diff.removed.length} / ~${diff.changed.length})`
);
if (opts.fromMeta.duplicateIds.length) {
console.log(
`! Warning: duplicate page ids in FROM: ${Array.from(new Set(opts.fromMeta.duplicateIds)).sort().join(', ')}`
);
}
if (opts.toMeta.duplicateIds.length) {
console.log(
`! Warning: duplicate page ids in TO: ${Array.from(new Set(opts.toMeta.duplicateIds)).sort().join(', ')}`
);
}
if (diff.added.length) {
console.log(`\n+ Added (${diff.added.length})`);
for (const { id, record } of diff.added) {
console.log(` + ${docLabel(id, record)}`);
}
}
if (diff.removed.length) {
console.log(`\n- Removed (${diff.removed.length})`);
for (const { id, record } of diff.removed) {
console.log(` - ${docLabel(id, record)}`);
}
}
if (diff.changed.length) {
console.log(`\n~ Changed (${diff.changed.length})`);
for (const change of diff.changed) {
const fromTitle = change.fromRecord.title;
const toTitle = change.toRecord.title;
let header = ` ~ ${change.id}`;
if (
typeof fromTitle === 'string' &&
typeof toTitle === 'string' &&
fromTitle !== toTitle
) {
header += ` "${fromTitle}" -> "${toTitle}"`;
} else if (typeof toTitle === 'string' && toTitle.trim()) {
header += ` "${toTitle}"`;
}
console.log(header);
for (const field of change.fields) {
console.log(
` - ${field.key}: ${formatValue(field.key, field.from)} -> ${formatValue(field.key, field.to)}`
);
}
}
}
if (!diff.added.length && !diff.removed.length && !diff.changed.length) {
console.log('\n(no changes)');
}
}

227
tools/doc-diff/src/table.ts Normal file
View File

@@ -0,0 +1,227 @@
import type { Doc } from 'yjs';
import { Map as YMap } from 'yjs';
import { diffKeyedRecords, type KeyedRecord } from './diff';
import { formatValue, toPlain } from './plain';
const DELETE_FLAG_KEY = '$$DELETED';
type TableExtract = {
recordsByKey: Map<string, KeyedRecord>;
duplicateKeys: string[];
};
function getRecordKey(record: YMap<any>, keyField: string): string | null {
const keyRaw = record.get(keyField);
if (typeof keyRaw === 'string') {
return keyRaw;
}
if (typeof keyRaw?.toString === 'function') {
return keyRaw.toString();
}
return null;
}
function isDeletedRecord(record: YMap<any>): boolean {
return record.get(DELETE_FLAG_KEY) === true || record.size === 0;
}
export function extractYjsTable(doc: Doc, keyField: string): TableExtract {
const recordsByKey = new Map<string, KeyedRecord>();
const duplicateKeys: string[] = [];
for (const sharedKey of doc.share.keys()) {
let record: unknown;
try {
record = doc.getMap(sharedKey);
} catch {
// Not a YMap shared type; ignore.
continue;
}
if (!(record instanceof YMap)) {
continue;
}
const key = getRecordKey(record, keyField);
if (!key) {
continue;
}
if (isDeletedRecord(record)) {
continue;
}
if (recordsByKey.has(key)) {
duplicateKeys.push(key);
}
const recordPlain = toPlain(record) as unknown;
if (
recordPlain &&
typeof recordPlain === 'object' &&
!Array.isArray(recordPlain)
) {
recordsByKey.set(key, recordPlain as KeyedRecord);
}
}
return { recordsByKey, duplicateKeys };
}
function folderLabel(id: string, record: KeyedRecord) {
const type = record.type;
const data = record.data;
if (type === 'folder') {
const name = typeof data === 'string' && data.trim() ? `"${data}"` : '';
return `folder:${id}${name ? ` ${name}` : ''}`;
}
if (typeof type === 'string' && typeof data === 'string') {
return `${type}:${data} (id=${id})`;
}
return `id=${id}`;
}
function favoriteLabel(key: string) {
return key;
}
export function printFolderPairDiff(opts: {
fromLabel: string;
toLabel: string;
fromTable: TableExtract;
toTable: TableExtract;
}) {
const diff = diffKeyedRecords(
opts.fromTable.recordsByKey,
opts.toTable.recordsByKey
);
console.log(`\n=== ${opts.fromLabel} -> ${opts.toLabel} ===`);
console.log(
`Rows: ${opts.fromTable.recordsByKey.size} -> ${opts.toTable.recordsByKey.size} (+${diff.added.length} / -${diff.removed.length} / ~${diff.changed.length})`
);
if (opts.fromTable.duplicateKeys.length) {
console.log(
`! Warning: duplicate keys in FROM: ${Array.from(new Set(opts.fromTable.duplicateKeys)).sort().join(', ')}`
);
}
if (opts.toTable.duplicateKeys.length) {
console.log(
`! Warning: duplicate keys in TO: ${Array.from(new Set(opts.toTable.duplicateKeys)).sort().join(', ')}`
);
}
if (diff.added.length) {
console.log(`\n+ Added (${diff.added.length})`);
for (const { id, record } of diff.added) {
const parentId = record.parentId;
const index = record.index;
console.log(
` + ${folderLabel(id, record)} (parentId=${formatValue('parentId', parentId ?? null)}, index=${formatValue('index', index ?? null)})`
);
}
}
if (diff.removed.length) {
console.log(`\n- Removed (${diff.removed.length})`);
for (const { id, record } of diff.removed) {
const parentId = record.parentId;
const index = record.index;
console.log(
` - ${folderLabel(id, record)} (parentId=${formatValue('parentId', parentId ?? null)}, index=${formatValue('index', index ?? null)})`
);
}
}
if (diff.changed.length) {
console.log(`\n~ Changed (${diff.changed.length})`);
for (const change of diff.changed) {
const fromName =
change.fromRecord.type === 'folder' ? change.fromRecord.data : null;
const toName =
change.toRecord.type === 'folder' ? change.toRecord.data : null;
let header = ` ~ ${folderLabel(change.id, change.toRecord)}`;
if (
typeof fromName === 'string' &&
typeof toName === 'string' &&
fromName !== toName
) {
header += ` ("${fromName}" -> "${toName}")`;
}
console.log(header);
for (const field of change.fields) {
console.log(
` - ${field.key}: ${formatValue(field.key, field.from)} -> ${formatValue(field.key, field.to)}`
);
}
}
}
if (!diff.added.length && !diff.removed.length && !diff.changed.length) {
console.log('\n(no changes)');
}
}
export function printFavoritePairDiff(opts: {
fromLabel: string;
toLabel: string;
fromTable: TableExtract;
toTable: TableExtract;
}) {
const diff = diffKeyedRecords(
opts.fromTable.recordsByKey,
opts.toTable.recordsByKey
);
console.log(`\n=== ${opts.fromLabel} -> ${opts.toLabel} ===`);
console.log(
`Rows: ${opts.fromTable.recordsByKey.size} -> ${opts.toTable.recordsByKey.size} (+${diff.added.length} / -${diff.removed.length} / ~${diff.changed.length})`
);
if (opts.fromTable.duplicateKeys.length) {
console.log(
`! Warning: duplicate keys in FROM: ${Array.from(new Set(opts.fromTable.duplicateKeys)).sort().join(', ')}`
);
}
if (opts.toTable.duplicateKeys.length) {
console.log(
`! Warning: duplicate keys in TO: ${Array.from(new Set(opts.toTable.duplicateKeys)).sort().join(', ')}`
);
}
if (diff.added.length) {
console.log(`\n+ Added (${diff.added.length})`);
for (const { id, record } of diff.added) {
console.log(
` + ${favoriteLabel(id)} (index=${formatValue('index', record.index ?? null)})`
);
}
}
if (diff.removed.length) {
console.log(`\n- Removed (${diff.removed.length})`);
for (const { id, record } of diff.removed) {
console.log(
` - ${favoriteLabel(id)} (index=${formatValue('index', record.index ?? null)})`
);
}
}
if (diff.changed.length) {
console.log(`\n~ Changed (${diff.changed.length})`);
for (const change of diff.changed) {
console.log(` ~ ${favoriteLabel(change.id)}`);
for (const field of change.fields) {
console.log(
` - ${field.key}: ${formatValue(field.key, field.from)} -> ${formatValue(field.key, field.to)}`
);
}
}
}
if (!diff.added.length && !diff.removed.length && !diff.changed.length) {
console.log('\n(no changes)');
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["./src"],
"references": [{ "path": "../cli" }]
}

View File

@@ -0,0 +1,76 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { Package } from '@affine-tools/utils/workspace';
import {
androidpublisher_v3,
auth as google_auth,
} from '@googleapis/androidpublisher';
export async function fetchVersionCode(applicationId: string): Promise<number> {
const auth = new google_auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
});
const androidPublisher = new androidpublisher_v3.Androidpublisher({
auth,
});
const appEdit = await androidPublisher.edits.insert({
packageName: applicationId,
requestBody: {
// 20min
expiryTimeSeconds: (Math.floor(Date.now() / 1000) + 12000).toString(),
},
});
if (!appEdit.data.id) {
throw new Error('Failed to create edit');
}
const lists = await androidPublisher.edits.bundles.list({
packageName: applicationId,
editId: appEdit.data.id,
});
let versionCode: number = 1;
try {
versionCode =
lists.data.bundles?.[lists.data.bundles.length - 1].versionCode || 1;
} catch {}
console.info(`Remote version code: ${versionCode}`);
console.info(`Writing edit ID to ${process.env.GITHUB_OUTPUT}`);
if (process.env.GITHUB_OUTPUT) {
execSync(
`echo "EDIT_ID=${appEdit.data.id}" >> ${process.env.GITHUB_OUTPUT}`,
{
stdio: 'inherit',
}
);
}
return versionCode;
}
const versionCodeRegexPattern = /(versionCode(?:\s|=)*)(.*)/;
const gradlePath = new Package('@affine/android').join(
'App/app/build.gradle'
).value;
let gradleVersionCode = 0;
const gradleFile = fs.readFileSync(gradlePath, 'utf8');
const matched = gradleFile.match(versionCodeRegexPattern);
const remoteVersion = await fetchVersionCode('app.affine.pro');
gradleVersionCode = parseInt(matched?.[2] || '0');
gradleVersionCode = isNaN(gradleVersionCode) ? 0 : gradleVersionCode;
const versionCode = Math.max(gradleVersionCode, remoteVersion) + 1;
fs.writeFileSync(
gradlePath,
gradleFile.replace(versionCodeRegexPattern, `$1 ${versionCode}`)
);

View File

@@ -0,0 +1,20 @@
{
"name": "@affine/playstore-auto-bump",
"version": "0.27.5",
"private": true,
"type": "module",
"description": "Automatically bump the versionCode and versionName of an Android app from the Google Play Store versions",
"main": "index.ts",
"scripts": {
"bump": "r ./index.ts"
},
"dependencies": {
"@affine-tools/cli": "workspace:*",
"@affine-tools/utils": "workspace:*",
"@googleapis/androidpublisher": "^36.0.0",
"typescript": "npm:@typescript/typescript6@6.0.2"
},
"devDependencies": {
"@types/node": "^22.0.0"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"outDir": "dist"
},
"files": ["index.ts"],
"references": [{ "path": "../cli" }, { "path": "../utils" }]
}

View File

@@ -0,0 +1,180 @@
import { readFileSync, writeFileSync } from 'node:fs';
import {
applyUpdate,
Doc,
encodeStateAsUpdate,
encodeStateVector,
UndoManager,
} from 'yjs';
type InputFormat = 'file' | 'base64' | 'hex';
type OutputFormat = 'bin' | 'base64' | 'hex';
const HELP_TEXT = `
Generate a revert update from two Yjs snapshot binaries.
Usage:
r ./tools/revert-update/index.ts --from <value> --to <value> [options]
Options:
--from <value> Newer snapshot input (path/base64/hex)
--to <value> Older snapshot input (path/base64/hex)
--from-format <fmt> file|base64|hex (default: file)
--to-format <fmt> file|base64|hex (default: file)
--out <path> Output path (default: stdout)
--out-format <fmt> bin|base64|hex (default: base64 if stdout, bin if file)
-h, --help Show help
Examples:
r ./tools/revert-update/index.ts --from ./from.bin --to ./to.bin --out ./revert.bin
r ./tools/revert-update/index.ts --from "$FROM" --from-format base64 --to "$TO" --to-format base64
`;
function generateRevertUpdate(
fromNewerBin: Uint8Array,
toOlderBin: Uint8Array
): Uint8Array {
const newerDoc = new Doc();
applyUpdate(newerDoc, fromNewerBin);
const olderDoc = new Doc();
applyUpdate(olderDoc, toOlderBin);
const newerState = encodeStateVector(newerDoc);
const olderState = encodeStateVector(olderDoc);
const diff = encodeStateAsUpdate(newerDoc, olderState);
const undoManager = new UndoManager(Array.from(olderDoc.share.values()));
applyUpdate(olderDoc, diff);
undoManager.undo();
return encodeStateAsUpdate(olderDoc, newerState);
}
function fail(message: string): never {
console.error(message);
console.error(HELP_TEXT.trim());
process.exit(1);
}
function parseInputFormat(value: string, flag: string): InputFormat {
switch (value) {
case 'file':
case 'base64':
case 'hex':
return value;
default:
fail(`Unknown ${flag} value: ${value}`);
}
}
function parseOutputFormat(value: string, flag: string): OutputFormat {
switch (value) {
case 'bin':
case 'base64':
case 'hex':
return value;
default:
fail(`Unknown ${flag} value: ${value}`);
}
}
function parseArgs(argv: string[]) {
const args = new Map<string, string>();
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
console.log(HELP_TEXT.trim());
process.exit(0);
}
if (!arg.startsWith('--')) {
fail(`Unknown argument: ${arg}`);
}
const value = argv[i + 1];
if (!value || value.startsWith('--')) {
fail(`Missing value for ${arg}`);
}
args.set(arg, value);
i += 1;
}
const from = args.get('--from');
const to = args.get('--to');
if (!from || !to) {
fail('Both --from and --to are required.');
}
const fromFormat = parseInputFormat(
(args.get('--from-format') ?? 'file').toLowerCase(),
'--from-format'
);
const toFormat = parseInputFormat(
(args.get('--to-format') ?? 'file').toLowerCase(),
'--to-format'
);
const outPath = args.get('--out');
const defaultOutFormat = outPath ? 'bin' : 'base64';
const outFormat = parseOutputFormat(
(args.get('--out-format') ?? defaultOutFormat).toLowerCase(),
'--out-format'
);
return {
from,
to,
fromFormat,
toFormat,
outPath,
outFormat,
};
}
function readInput(value: string, format: InputFormat): Uint8Array {
try {
if (format === 'file') {
return new Uint8Array(readFileSync(value));
}
const trimmed = value.trim();
const decoded = Buffer.from(trimmed, format === 'hex' ? 'hex' : 'base64');
return new Uint8Array(decoded);
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
fail(`Failed to read ${format} input: ${details}`);
}
}
function writeOutput(
update: Uint8Array,
outPath: string | undefined,
format: OutputFormat
) {
if (outPath) {
if (format === 'bin') {
writeFileSync(outPath, update);
return;
}
const encoded = Buffer.from(update).toString(format);
writeFileSync(outPath, encoded);
return;
}
if (format === 'bin') {
process.stdout.write(Buffer.from(update));
return;
}
const encoded = Buffer.from(update).toString(format);
process.stdout.write(`${encoded}\n`);
}
const { from, to, fromFormat, toFormat, outPath, outFormat } = parseArgs(
process.argv.slice(2)
);
const fromBin = readInput(from, fromFormat);
const toBin = readInput(to, toFormat);
const revertUpdate = generateRevertUpdate(fromBin, toBin);
writeOutput(revertUpdate, outPath, outFormat);

View File

@@ -0,0 +1,19 @@
{
"name": "@affine/revert-update",
"version": "0.27.5",
"private": true,
"type": "module",
"description": "Generate a revert update from two Yjs snapshot binaries",
"main": "index.ts",
"scripts": {
"generate": "r ./index.ts"
},
"dependencies": {
"@affine-tools/cli": "workspace:*",
"yjs": "^13.6.27"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "npm:@typescript/typescript6@6.0.2"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"outDir": "dist"
},
"files": ["index.ts"],
"references": [{ "path": "../cli" }]
}

23
tools/utils/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@affine-tools/utils",
"version": "0.27.5",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts",
"./path": "./src/path.ts",
"./workspace": "./src/workspace.ts",
"./process": "./src/process.ts",
"./logger": "./src/logger.ts",
"./build-config": "./src/build-config.ts",
"./distribution": "./src/distribution.ts"
},
"dependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.0.0",
"chalk": "^5.3.0",
"lodash-es": "^4.17.23",
"oxfmt": "0.63.0",
"typescript": "npm:@typescript/typescript6@6.0.2"
}
}

View File

@@ -0,0 +1,100 @@
import type { Package } from '@affine-tools/utils/workspace';
import { PackageToDistribution } from './distribution';
export interface BuildFlags {
channel: 'stable' | 'beta' | 'internal' | 'canary';
mode: 'development' | 'production';
}
export function getBuildConfig(
pkg: Package,
buildFlags: BuildFlags
): BUILD_CONFIG_TYPE {
const distribution = PackageToDistribution.get(pkg.name);
if (!distribution) {
throw new Error(`Distribution for ${pkg.name} is not found`);
}
const buildPreset: Record<BuildFlags['channel'], BUILD_CONFIG_TYPE> = {
get stable() {
return {
debug: buildFlags.mode === 'development',
distribution,
isDesktopEdition: (
['web', 'desktop', 'admin'] as BUILD_CONFIG_TYPE['distribution'][]
).includes(distribution),
isMobileEdition: (
['mobile', 'ios', 'android'] as BUILD_CONFIG_TYPE['distribution'][]
).includes(distribution),
isElectron: distribution === 'desktop',
isWeb: distribution === 'web',
isMobileWeb: distribution === 'mobile',
isIOS: distribution === 'ios',
isAndroid: distribution === 'android',
isNative:
distribution === 'desktop' ||
distribution === 'ios' ||
distribution === 'android',
isAdmin: distribution === 'admin',
appBuildType: 'stable' as const,
appVersion: pkg.version,
// editorVersion: pkg.dependencies['@blocksuite/affine'],
editorVersion: pkg.version,
githubUrl: 'https://github.com/toeverything/AFFiNE',
changelogUrl: 'https://affine.pro/what-is-new',
downloadUrl: 'https://affine.pro/download',
pricingUrl: 'https://affine.pro/pricing',
discordUrl: 'https://affine.pro/redirect/discord',
requestLicenseUrl: 'https://affine.pro/redirect/license',
imageProxyUrl: '/api/worker/image-proxy',
linkPreviewUrl: '/api/worker/link-preview',
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
};
},
get beta() {
return {
...this.stable,
appBuildType: 'beta' as const,
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
};
},
get internal() {
return {
...this.stable,
appBuildType: 'internal' as const,
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
};
},
// canary will be aggressive and enable all features
get canary() {
return {
...this.stable,
appBuildType: 'canary' as const,
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
};
},
};
const currentBuild = buildFlags.channel;
if (!(currentBuild in buildPreset)) {
throw new Error(`BUILD_TYPE ${currentBuild} is not supported`);
}
const currentBuildPreset = buildPreset[currentBuild];
const environmentPreset = {
changelogUrl: process.env.CHANGELOG_URL ?? currentBuildPreset.changelogUrl,
};
return {
...currentBuildPreset,
// environment preset will overwrite current build preset
// this environment variable is for debug proposes only
// do not put them into CI
...(process.env.CI ? {} : environmentPreset),
};
}

View File

@@ -0,0 +1,30 @@
import { PackageList, type PackageName } from './yarn';
export const PackageToDistribution = new Map<
PackageName,
BUILD_CONFIG_TYPE['distribution']
>([
['@affine/admin', 'admin'],
['@affine/web', 'web'],
['@affine/electron-renderer', 'desktop'],
['@affine/electron', 'desktop'],
['@affine/mobile', 'mobile'],
['@affine/ios', 'ios'],
['@affine/android', 'android'],
]);
export const AliasToPackage = new Map<string, PackageName>([
['admin', '@affine/admin'],
['web', '@affine/web'],
['electron', '@affine/electron'],
['desktop', '@affine/electron-renderer'],
['renderer', '@affine/electron-renderer'],
['mobile', '@affine/mobile'],
['ios', '@affine/ios'],
['android', '@affine/android'],
['server', '@affine/server'],
['gql', '@affine/graphql'],
...PackageList.map(
pkg => [pkg.name.split('/').pop()!, pkg.name] as [string, PackageName]
),
]);

22
tools/utils/src/format.ts Normal file
View File

@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs';
import { once } from 'lodash-es';
import { format, type FormatConfig } from 'oxfmt';
import { ProjectRoot } from './path';
const readConfig = once(() => {
const path = ProjectRoot.join('.oxfmtrc.json').value;
const config = JSON.parse(readFileSync(path, 'utf-8')) as FormatConfig;
return config;
});
export async function formatCode(content: string, fileName: string) {
const result = await format(fileName, content, readConfig());
if (result.errors.length) {
throw new Error(
result.errors.map(error => error.codeframe ?? error.message).join('\n')
);
}
return result.code;
}

2
tools/utils/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './format';
export * from './path';

46
tools/utils/src/logger.ts Normal file
View File

@@ -0,0 +1,46 @@
import chalk from 'chalk';
import { identity } from 'lodash-es';
export const newLineSeparator = /\r\n|[\n\r\x85\u2028\u2029]/g;
interface StringLike {
toString: () => string;
}
export class Logger {
log = this.getLineLogger(console.log.bind(console));
info = this.getLineLogger(console.info.bind(console), chalk.blue);
warn = this.getLineLogger(
console.warn.bind(console),
chalk.bgHex('#322b08').hex('#fadea6')
);
error = this.getLineLogger(
console.error.bind(console),
chalk.bgHex('#250201').hex('#ef8784')
);
success = this.getLineLogger(console.log.bind(console), chalk.green);
constructor(private readonly tag: string = '') {}
getLineLogger(
logLine: (...line: string[]) => void,
color: (...text: string[]) => string = identity
) {
return (...args: StringLike[]) => {
args.forEach(arg => {
arg
.toString()
.split(newLineSeparator)
.forEach(line => {
if (line.length !== 0) {
if (this.tag) {
logLine(color(`[${this.tag}] ${line}`));
} else {
logLine(color(line));
}
}
});
});
};
}
}

View File

@@ -0,0 +1,81 @@
import { readFileSync } from 'node:fs';
import { parse } from 'node:path';
import { type Path, ProjectRoot } from './path';
import type { CommonPackageJsonContent, YarnWorkspaceItem } from './types';
import type { Workspace } from './workspace';
import { PackageList, type PackageName } from './yarn';
export function readPackageJson(path: Path): CommonPackageJsonContent {
const content = readFileSync(path.join('package.json').toString(), 'utf-8');
return JSON.parse(content);
}
export class Package {
readonly name: PackageName;
readonly packageJson: CommonPackageJsonContent;
readonly dirname: string;
readonly path: Path;
readonly srcPath: Path;
readonly nodeModulesPath: Path;
readonly libPath: Path;
readonly distPath: Path;
readonly version: string;
readonly isTsProject: boolean;
readonly workspaceDependencies: string[];
readonly deps: Package[] = [];
private _workspace: Workspace | null = null;
get entry() {
return this.packageJson.main || this.packageJson.exports?.['.'];
}
get dependencies() {
return this.packageJson.dependencies || {};
}
get devDependencies() {
return this.packageJson.devDependencies || {};
}
get workspace() {
if (!this._workspace) {
throw new Error('Workspace is not initialized');
}
return this._workspace;
}
private set workspace(workspace: Workspace) {
this._workspace = workspace;
}
constructor(name: PackageName, meta?: YarnWorkspaceItem) {
this.name = name as PackageName;
meta ??= PackageList.find(item => item.name === name)!;
// parse paths
this.path = ProjectRoot.join(meta.location);
this.dirname = parse(meta.location).name;
this.srcPath = this.path.join('src');
this.libPath = this.path.join('lib');
this.distPath = this.path.join('dist');
this.nodeModulesPath = this.path.join('node_modules');
// parse workspace
const packageJson = readPackageJson(this.path);
this.packageJson = packageJson;
this.version = packageJson.version;
this.workspaceDependencies = meta.workspaceDependencies;
this.isTsProject = this.path.join('tsconfig.json').isFile();
}
get scripts() {
return this.packageJson.scripts || {};
}
join(...paths: string[]) {
return this.path.join(...paths);
}
}

93
tools/utils/src/path.ts Normal file
View File

@@ -0,0 +1,93 @@
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
export class Path {
static dir(url: string) {
return new Path(fileURLToPath(url)).join('..');
}
get value() {
return this.path;
}
get relativePath() {
return './' + this.path.slice(ProjectRoot.path.length).replace(/\\/g, '/');
}
constructor(private readonly path: string) {}
join(...paths: string[]) {
return new Path(join(this.path, ...paths));
}
parent() {
return this.join('..');
}
toPosixString() {
if (sep === '\\') {
return this.path.replaceAll('\\', '/');
}
return this.path;
}
toString() {
return this.path;
}
exists() {
return existsSync(this.path);
}
rm(opts: { recursive?: boolean } = {}) {
rmSync(this.path, { ...opts, force: true });
}
mkdir() {
mkdirSync(this.path, { recursive: true });
}
readAsFile() {
return readFileSync(this.path);
}
writeFile(content: Buffer | string) {
writeFileSync(this.path, content);
}
stats() {
return statSync(this.path);
}
isFile() {
return this.exists() && this.stats().isFile();
}
isDirectory() {
return this.exists() && this.stats().isDirectory();
}
toFileUrl() {
return pathToFileURL(this.path);
}
relative(to: string) {
const re = relative(this.value, to);
if (sep === '\\') {
return re.replaceAll('\\', '/');
}
return re;
}
}
export const ProjectRoot = Path.dir(import.meta.url).join('../../../');

View File

@@ -0,0 +1,99 @@
import {
type ChildProcess,
execSync,
spawn as RawSpawn,
type SpawnOptions,
} from 'node:child_process';
import { Logger } from './logger';
const children = new Set<ChildProcess>();
export function spawn(
tag: string,
cmd: string | string[],
options: SpawnOptions = {}
) {
cmd = typeof cmd === 'string' ? cmd.split(' ') : cmd;
const isYarnSpawn = cmd[0] === 'yarn';
const spawnOptions: SpawnOptions = {
stdio: isYarnSpawn
? ['inherit', 'inherit', 'inherit']
: ['inherit', 'pipe', 'pipe'],
shell: true,
...options,
env: { ...process.env, ...options.env },
};
const logger = new Logger(tag);
logger.info(cmd.join(' '));
const childProcess = RawSpawn(cmd[0], cmd.slice(1), spawnOptions);
children.add(childProcess);
const drain = (_code: number | null, signal: any) => {
children.delete(childProcess);
// don't run repeatedly if this is the error event
if (signal === undefined) {
childProcess.removeListener('exit', drain);
}
};
childProcess.stdout?.on('data', chunk => {
logger.log(chunk);
});
childProcess.stderr?.on('data', chunk => {
logger.error(chunk);
});
childProcess.once('error', e => {
logger.error(e.toString());
children.delete(childProcess);
});
childProcess.once('exit', (code, signal) => {
if (code !== 0) {
logger.error('Finished with non-zero exit code.');
}
drain(code, signal);
});
return childProcess;
}
export function execAsync(
tag: string,
cmd: string | string[],
options?: SpawnOptions
): Promise<void> {
return new Promise((resolve, reject) => {
const childProcess = spawn(tag, cmd, options);
childProcess.once('error', e => {
reject(e);
});
childProcess.once('exit', code => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Child process exits with non-zero code ${code}`));
}
});
});
}
export function exec(
tag: string,
cmd: string,
{ silent }: { silent: boolean } = { silent: false }
): string {
const logger = new Logger(tag);
!silent && logger.info(cmd);
const result = execSync(cmd, { encoding: 'utf8' }).trim();
!silent && logger.log(result);
return result;
}

19
tools/utils/src/types.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface YarnWorkspaceItem {
name: string;
location: string;
workspaceDependencies: string[];
// we don't need it
mismatchedWorkspaceDependencies?: string[];
}
export interface CommonPackageJsonContent {
name: string;
type?: 'module' | 'commonjs';
version: string;
private?: boolean;
dependencies?: { [key: string]: string };
devDependencies?: { [key: string]: string };
scripts?: { [key: string]: string };
main?: string;
exports?: { [key: string]: string | { [key: string]: string } };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import { Logger } from './logger';
import { Package, readPackageJson } from './package';
import { ProjectRoot } from './path';
import type { CommonPackageJsonContent } from './types';
import { PackageList, type PackageName, yarnList } from './yarn';
class CircularDependenciesError extends Error {
constructor(public currentName: string) {
super('Circular dependencies error');
}
}
class ForbiddenPackageRefError extends Error {
constructor(
public currentName: string,
public refName: string
) {
super(
`Public package cannot reference private package. Found '${refName}' in dependencies of '${currentName}'`
);
}
}
export class Workspace {
static PackageNames: PackageName[] = PackageList.map(
p => p.name
) as PackageName[];
readonly packages: Package[];
readonly packageJson: CommonPackageJsonContent;
private readonly logger = new Logger('AFFiNE');
readonly path = ProjectRoot;
get version() {
return this.packageJson.version;
}
get devDependencies() {
return this.packageJson.devDependencies ?? {};
}
get dependencies() {
return this.packageJson.dependencies ?? {};
}
get isTsProject() {
return this.join('tsconfig.json').exists();
}
constructor(list: typeof PackageList = PackageList) {
this.packageJson = readPackageJson(ProjectRoot);
const packages = new Map<string, Package>();
for (const meta of list) {
try {
const pkg = new Package(meta.name as PackageName, meta);
// @ts-expect-error internal api
pkg.workspace = this;
packages.set(meta.location, pkg);
} catch (e) {
this.logger.error(e as Error);
}
}
const building = new Set<string>();
try {
packages.forEach(pkg => this.buildDeps(pkg, packages, building));
} catch (e) {
if (e instanceof CircularDependenciesError) {
const inProcessPackages = Array.from(building);
console.log(inProcessPackages, e.currentName);
const circle = inProcessPackages
.slice(inProcessPackages.indexOf(e.currentName))
.concat(e.currentName);
this.logger.error(
`Circular dependencies found: \n ${circle.join(' -> ')}`
);
process.exit(1);
}
throw e;
}
this.packages = Array.from(packages.values());
}
tryGetPackage(name: PackageName) {
return this.packages.find(p => p.name === name);
}
getPackage(name: PackageName) {
const pkg = this.tryGetPackage(name);
if (!pkg) {
throw new Error(`Cannot find package with name '${name}'`);
}
return pkg;
}
join(...paths: string[]) {
return this.path.join(...paths);
}
buildDeps(
pkg: Package,
packages: Map<string, Package>,
building: Set<string>
) {
if (pkg.deps.length) {
return;
}
building.add(pkg.name);
// @ts-expect-error workspace is the builder for package deps
pkg.deps = pkg.workspaceDependencies
.map(relativeDepPath => {
const dep = packages.get(relativeDepPath);
if (!dep) {
this.logger.error(
`Cannot find package at ${relativeDepPath}. While build dependencies of ${pkg.name}`
);
return null;
}
if (building.has(dep.name)) {
throw new CircularDependenciesError(dep.name);
}
if (!pkg.packageJson.private && dep.packageJson.private) {
throw new ForbiddenPackageRefError(pkg.name, dep.name);
}
this.buildDeps(dep, packages, building);
return dep;
})
.filter(Boolean) as Package[];
building.delete(pkg.name);
}
forEach(callback: (pkg: Package) => void) {
this.packages.forEach(callback);
}
}
export { Package, type PackageName, yarnList };

36
tools/utils/src/yarn.ts Normal file
View File

@@ -0,0 +1,36 @@
import { once } from 'lodash-es';
import { Logger } from './logger';
import { exec } from './process';
import type { YarnWorkspaceItem } from './types';
import type { PackageName } from './workspace.gen';
async function loadPackageList() {
try {
const packageList = await import('./workspace.gen');
return packageList.PackageList;
} catch (e) {
console.log(e);
new Logger('yarn').error('Failed to load package list');
return [];
}
}
export const PackageList = await loadPackageList();
export type { PackageName };
export const yarnList = once(() => {
const output = exec('', 'yarn workspaces list -v --json', { silent: true });
let packageList = JSON.parse(
`[${output.trim().replace(/\r\n|\n/g, ',')}]`
) as YarnWorkspaceItem[];
packageList.forEach(p => {
p.location = p.location.replaceAll(/\\/g, '/');
delete p['mismatchedWorkspaceDependencies'];
});
// ignore root package
return packageList.filter(p => p.location !== '.');
});

10
tools/utils/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": []
}