super-productivity/packages/plugin-dev/scripts/build-all.js
Johannes Millan 2580ea6eb1
perf(plugins): skip no-op doc-mode saves + rename to doc-mode (#7825)
* perf(plugins): skip no-op doc-mode saves via lastSeenDocBytes equality

Closes #7815.

flushSave and flushSaveSync now compute the would-be-written raw bytes
first and short-circuit when they equal lastSeenDocBytes — a typed-then-
reverted cycle inside the 30s throttle window no longer produces a
postMessage round-trip, an IDB transaction, or an op-log entry.

The baseline is intentionally NOT updated on a skip: it already equals
these bytes, so the self-echo invariant for PERSISTED_DATA_CHANGED
(#7752) is preserved.

Drive-by: added the isDocCorrupt guard to flushSave for symmetry with
flushSaveSync / scheduleSave. The schedule gate normally prevents
flushSave from firing on a corrupt doc, but corruption can be set
between schedule and fire (e.g. a remote-update reload turning up an
unparseable blob), so the explicit guard is cheaper than reasoning
about that invariant.

Persistence rename: saveContextDoc → persistContextDocRaw. The caller
now owns serialisation (via serializeContextDoc) so the byte-compare
can run before the persist dispatch. Three persistence.spec.ts tests
replace the dropped saveContextDoc test: exact-raw write, encoder
determinism (the premise the byte-compare relies on), and
write-idempotence (which proves the skip loses no information).

* refactor(plugins): apply doc-mode no-op-save review findings (#7815)

Multi-review follow-ups for #7815:

- Tighten serializeContextDoc determinism test: previously asserted
  equality between a doc and its `{...doc}` spread, which preserves V8
  insertion order — the test would pass even against a future encoder
  that randomised iteration over Map-backed nodes (the real failure
  mode for the byte-compare). Now asserts repeated-call determinism on
  the same input directly, plus type+non-empty shape so a future return-
  type change (e.g. Uint8Array) breaks here, not in the editor.

- Document the sync/async stamp asymmetry in flushSaveSync: it stamps
  lastSeenDocBytes BEFORE the void dispatch, whereas flushSave stamps
  AFTER `await`. Both are correct (the sync path is fire-and-forget and
  the iframe can be torn down mid-call, so a post-dispatch stamp would
  silently drop on teardown) but the divergence wasn't called out in
  the inline comments.

* refactor(plugins): rename document-mode to doc-mode

Renames the bundled plugin's id (`document-mode` → `doc-mode`), display
name (`Document Mode (Alpha)` → `Doc Mode (Alpha)`), package name,
directory, and asset path. No migration: the plugin has never been
published, so there is no on-disk user data to preserve.

Touched everywhere the id was hardcoded:
- Plugin manifest, package.json, build/deploy/test scripts, log prefixes
- Host BUNDLED_PLUGIN_PATHS entry (src/app/plugins/plugin.service.ts)
- Host comments and test fixtures (plugin-hooks, plugin-persistence-key,
  plugin-persistence.model, conflict-resolution.service.spec)
- E2E spec filenames + PLUGIN_ID constant + label assertions
- build-all.js plugin orchestrator
- build/release-notes.md user-facing entry

Test pass: 88/88 plugin specs, 8/8 plugin-hooks, 132/132
conflict-resolution, 15/15 plugin-persistence-key.util.
Bundle redeployed to src/assets/bundled-plugins/doc-mode (gitignored).

The old `src/assets/bundled-plugins/document-mode/` dir is gitignored and
left on disk after this commit; the host loader no longer references it,
so it's inert. Remove with `rm -rf src/assets/bundled-plugins/document-mode`.

* refactor(plugins): apply doc-mode rename review findings

Multi-review follow-ups for the rename in 3443bcacd0:

- editor.ts:1920: missed runtime log string ('Document mode:' →
  'doc-mode:') — leaked into the dev console with inconsistent brand
  because the rename sweep matched on the kebab `document-mode` or the
  PascalCase `Document Mode`, not the bare-space `Document mode`.
- editor.ts + background.ts header JSDoc: `Document-Mode editor` /
  `Document-Mode background script` → `Doc-Mode …`. Cosmetic but the
  only remaining branded references in the source.
- scripts/build.js: esbuild iife `globalName: 'DocumentModePlugin'` →
  `'DocModePlugin'`. Internal global, no consumers, but worth keeping
  consistent with the new id.
- src/app/plugins/plugin.service.ts: add a header comment to
  BUNDLED_PLUGIN_PATHS noting that pluginIds become entityId prefixes in
  IDB / op-log / sync wire and must be treated as permanent for any
  plugin that has ever shipped. Records the rule we relied on being
  absent for this rename.

DOM-document references (`installDocumentDragHandlers`,
`Document-status banner`, `Document-level pointer handlers`) left
unchanged — those refer to the browser document object, not the brand.
Historical plan docs in `docs/plans/2026-05-2[123]-*.md` also left
unchanged per the rename commit's intent.
2026-05-27 17:30:38 +02:00

487 lines
15 KiB
JavaScript
Executable file

#!/usr/bin/env node
const { exec } = require('child_process');
const { promisify } = require('util');
const fs = require('fs');
const path = require('path');
const execAsync = promisify(exec);
// Colors for console output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
};
function log(message, color = '') {
if (process.argv.includes('--silent')) return;
console.log(`${color}${message}${colors.reset}`);
}
// Recursive copy function that handles both files and directories
function copyRecursive(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
copyRecursive(path.join(src, entry), path.join(dest, entry));
}
} else {
fs.copyFileSync(src, dest);
}
}
// Plugin configurations
const plugins = [
{
name: 'procrastination-buster',
path: 'procrastination-buster',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
// Copy to assets directory
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/procrastination-buster',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
const src = path.join(distPath, file);
const dest = path.join(targetDir, file);
copyRecursive(src, dest);
}
}
return 'Built and copied to assets';
},
},
// Migrated built-in plugins
{
name: 'api-test-plugin',
path: 'api-test-plugin',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
// Copy to assets directory
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/api-test-plugin',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const files = [
'manifest.json',
'plugin.js',
'index.html',
'config-schema.json',
'icon.svg',
];
for (const file of files) {
const src = path.join(pluginPath, file);
const dest = path.join(targetDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
}
}
return 'Copied to assets';
},
},
{
name: 'sync-md',
path: 'sync-md',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
try {
// Try normal build first
await execAsync(`cd ${pluginPath} && npm run build`);
} catch (buildError) {
// If normal build fails, try emergency build
console.log(' Normal build failed, trying emergency build...');
await execAsync(`cd ${pluginPath} && node scripts/emergency-build.js`);
}
// Copy to assets directory
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/sync-md',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
const src = path.join(distPath, file);
const dest = path.join(targetDir, file);
copyRecursive(src, dest);
}
}
return 'Built and copied to assets';
},
},
{
name: 'yesterday-tasks-plugin',
path: 'yesterday-tasks-plugin',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
// Copy to assets directory
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/yesterday-tasks-plugin',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const files = ['manifest.json', 'plugin.js', 'index.html', 'icon.svg'];
for (const file of files) {
const src = path.join(pluginPath, file);
const dest = path.join(targetDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
}
}
return 'Copied to assets';
},
},
{
name: 'ai-productivity-prompts',
path: 'ai-productivity-prompts',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
// Copy to assets directory
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/ai-productivity-prompts',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
const src = path.join(distPath, file);
const dest = path.join(targetDir, file);
copyRecursive(src, dest);
}
}
return 'Built and copied to assets';
},
},
{
name: 'automations',
path: 'automations',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
// Copy to assets directory
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/automations',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
const src = path.join(distPath, file);
const dest = path.join(targetDir, file);
copyRecursive(src, dest);
}
}
return 'Built and copied to assets';
},
},
{
name: 'github-issue-provider',
path: 'github-issue-provider',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/github-issue-provider',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
const src = path.join(distPath, file);
const dest = path.join(targetDir, file);
copyRecursive(src, dest);
}
}
return 'Built and copied to assets';
},
},
{
name: 'voice-reminder',
path: 'voice-reminder',
needsInstall: false,
copyToAssets: true,
buildCommand: async (pluginPath) => {
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/voice-reminder',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const files = ['manifest.json', 'plugin.js', 'icon.svg'];
for (const file of files) {
const src = path.join(pluginPath, file);
const dest = path.join(targetDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
}
}
// Copy i18n directory
const i18nSrc = path.join(pluginPath, 'i18n');
if (fs.existsSync(i18nSrc)) {
copyRecursive(i18nSrc, path.join(targetDir, 'i18n'));
}
return 'Copied to assets';
},
},
{
name: 'clickup-issue-provider',
path: 'clickup-issue-provider',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/clickup-issue-provider',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
const src = path.join(distPath, file);
const dest = path.join(targetDir, file);
copyRecursive(src, dest);
}
}
return 'Built and copied to assets';
},
},
{
name: 'google-calendar-provider',
path: 'google-calendar-provider',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/google-calendar-provider',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
const src = path.join(distPath, file);
const dest = path.join(targetDir, file);
copyRecursive(src, dest);
}
}
return 'Built and copied to assets';
},
},
{
name: 'caldav-calendar-provider',
path: 'caldav-calendar-provider',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/caldav-calendar-provider',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
const src = path.join(distPath, file);
const dest = path.join(targetDir, file);
copyRecursive(src, dest);
}
}
return 'Built and copied to assets';
},
},
{
name: 'doc-mode',
path: 'doc-mode',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/doc-mode',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// editor.js is inlined into index.html; only ship the runtime files.
const distFiles = ['manifest.json', 'plugin.js', 'index.html', 'icon.svg'];
for (const file of distFiles) {
const src = path.join(pluginPath, 'dist', file);
const dest = path.join(targetDir, file);
if (fs.existsSync(src)) copyRecursive(src, dest);
}
return 'Built and copied to assets';
},
},
];
async function buildPlugin(plugin) {
const startTime = Date.now();
log(`\n📦 Building ${plugin.name}...`, colors.cyan);
try {
// Check if plugin directory exists
if (!fs.existsSync(plugin.path)) {
throw new Error(`Plugin directory not found: ${plugin.path}`);
}
// Install dependencies if needed
if (plugin.needsInstall) {
const packageJsonPath = path.join(plugin.path, 'package.json');
const nodeModulesPath = path.join(plugin.path, 'node_modules');
if (fs.existsSync(packageJsonPath)) {
log(` Installing dependencies...`, colors.yellow);
try {
// Try to install dependencies
await execAsync(`cd ${plugin.path} && npm install --include=dev`);
} catch (installError) {
// If install fails, check if node_modules exists and continue
if (fs.existsSync(nodeModulesPath)) {
log(
` Using existing dependencies (install failed but node_modules exists)`,
colors.yellow,
);
} else {
throw installError;
}
}
}
}
// Run build command
log(` Building...`, colors.yellow);
const result = await plugin.buildCommand(plugin.path);
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
log(`${plugin.name} - ${result} (${duration}s)`, colors.green);
return { plugin: plugin.name, success: true, duration };
} catch (error) {
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
log(`${plugin.name} - Build failed (${duration}s)`, colors.red);
if (error.stdout) {
log(` stdout: ${error.stdout}`, colors.red);
}
if (error.stderr) {
log(` stderr: ${error.stderr}`, colors.red);
}
return { plugin: plugin.name, success: false, error: error.message, duration };
}
}
async function buildAll() {
log('\n🚀 Building all plugins...', colors.bright);
const startTime = Date.now();
// Build plugins in parallel
const results = await Promise.all(plugins.map(buildPlugin));
// Summary
const totalDuration = ((Date.now() - startTime) / 1000).toFixed(1);
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
log('\n📊 Build Summary:', colors.bright);
log(` Total plugins: ${plugins.length}`);
log(` Successful: ${successful}`, colors.green);
if (failed > 0) {
log(` Failed: ${failed}`, colors.red);
}
log(` Total time: ${totalDuration}s`);
// List outputs
log('\n📁 Build outputs:', colors.bright);
// Check for minimal plugin zip
if (fs.existsSync('minimal-plugin.zip')) {
log(` • minimal-plugin.zip`);
}
// Check for other plugin outputs
for (const plugin of plugins.slice(1)) {
const distPath = path.join(plugin.path, 'dist');
if (fs.existsSync(distPath)) {
const pluginZip = path.join(distPath, 'plugin.zip');
if (fs.existsSync(pluginZip)) {
const stats = fs.statSync(pluginZip);
const size = (stats.size / 1024).toFixed(1);
log(`${plugin.path}/dist/plugin.zip (${size} KB)`);
} else {
log(`${plugin.path}/dist/`);
}
}
}
if (failed > 0) {
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
buildAll().catch((error) => {
log(`\n❌ Build failed: ${error.message}`, colors.red);
process.exit(1);
});
}
module.exports = { buildAll };