diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 16e3454..55678ef 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -170,6 +170,9 @@ jobs: working-directory: Frontend run: npm run build + - name: Build built-in plugins + run: node Backend/scripts/ensure-built-in-plugins.js + # ---------- Rust & platform deps ---------- - name: Install Rust (stable) uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # stable @@ -179,8 +182,6 @@ jobs: - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 - - name: Install OpenVCS SDK CLI - run: cargo install --locked openvcs-sdk --bin cargo-openvcs - name: Install Linux deps if: matrix.platform == 'ubuntu-24.04' diff --git a/.github/workflows/publish-stable.yml b/.github/workflows/publish-stable.yml index 2062e18..de80be5 100644 --- a/.github/workflows/publish-stable.yml +++ b/.github/workflows/publish-stable.yml @@ -60,6 +60,9 @@ jobs: working-directory: Frontend run: npm run build + - name: Build built-in plugins + run: node Backend/scripts/ensure-built-in-plugins.js + # ---------- Rust toolchain & deps ---------- - name: Install Rust (stable) uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # stable @@ -69,8 +72,6 @@ jobs: - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 - - name: Install OpenVCS SDK CLI - run: cargo install --locked openvcs-sdk --bin cargo-openvcs - name: Install Linux build deps (Ubuntu) if: matrix.platform == 'ubuntu-24.04' diff --git a/Backend/scripts/ensure-built-in-plugins.js b/Backend/scripts/ensure-built-in-plugins.js index d0140c3..e180dc8 100644 --- a/Backend/scripts/ensure-built-in-plugins.js +++ b/Backend/scripts/ensure-built-in-plugins.js @@ -7,15 +7,21 @@ const { spawnSync } = require('child_process'); const scriptDir = __dirname; const backendDir = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(backendDir, '..'); -const workspaceRoot = path.resolve(repoRoot, '..'); -const sdkDir = path.join(workspaceRoot, 'SDK'); const pluginSources = path.join(backendDir, 'built-in-plugins'); const pluginBundles = path.join(repoRoot, 'target', 'openvcs', 'built-in-plugins'); const nodeRuntimeDir = path.join(repoRoot, 'target', 'openvcs', 'node-runtime'); const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const forceRebuild = process.argv.includes('--force'); + const skipDirs = new Set(['target', '.git', 'node_modules', 'dist']); +/** + * Returns the newest file mtime (ms) under a directory, ignoring known build dirs. + * + * @param {string} dir - Directory to scan. + * @returns {number|null} Latest mtime or null when no files are present. + */ function latestSourceTime(dir) { let latest = 0; let hasFile = false; @@ -50,6 +56,12 @@ function latestSourceTime(dir) { return hasFile ? latest : null; } +/** + * Resolves the canonical built-in plugin bundle filename from plugin metadata. + * + * @param {string} name - Plugin source directory name. + * @returns {string} Expected `.ovcsp` filename. + */ function bundleFileNameForPlugin(name) { const manifestPath = path.join(pluginSources, name, 'openvcs.plugin.json'); try { @@ -58,7 +70,8 @@ function bundleFileNameForPlugin(name) { if (pluginId) { return `${pluginId}.ovcsp`; } - } catch { + } catch (e) { + console.debug(`Manifest unavailable for ${name}; using directory-name fallback.`, e); // Fall back to the directory name so the missing/invalid manifest still // forces a rebuild attempt and surfaces the real packaging error later. } @@ -87,6 +100,19 @@ function findOutdatedPlugins() { return outdated; } +/** + * Lists all plugin directories regardless of build state. + * + * @returns {string[]} Plugin directory names. + */ +function findAllPlugins() { + if (!fs.existsSync(pluginSources)) return []; + return fs + .readdirSync(pluginSources, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + function ensureBundlesDir() { fs.mkdirSync(pluginBundles, { recursive: true }); } @@ -169,24 +195,75 @@ function ensurePluginDependencies(pluginDir) { } } +/** + * Copies plugin archive(s) created by `npm run dist` into the app bundle output. + * + * @param {string} pluginName - Plugin directory name. + * @param {string} pluginDir - Plugin directory path. + */ +function copyPackagedBundles(pluginName, pluginDir) { + ensureBundlesDir(); + const distDir = path.join(pluginDir, 'dist'); + if (!fs.existsSync(distDir)) { + console.error(`Missing dist directory for ${pluginName}: ${distDir}`); + process.exit(1); + } + + const archiveEntries = fs + .readdirSync(distDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.ovcsp')); + + if (archiveEntries.length === 0) { + console.error(`No .ovcsp bundle produced for ${pluginName} in ${distDir}`); + process.exit(1); + } + + const preferredName = bundleFileNameForPlugin(pluginName); + const preferred = archiveEntries.find((entry) => entry.name === preferredName); + const sourceArchive = preferred + ? path.join(distDir, preferred.name) + : path.join(distDir, archiveEntries[0].name); + const destArchive = path.join(pluginBundles, preferredName); + + if (archiveEntries.length > 1) { + console.warn( + `Multiple .ovcsp archives found for ${pluginName}; using ${path.basename(sourceArchive)}.` + ); + } + + fs.copyFileSync(sourceArchive, destArchive); + console.log(`Built-in plugin bundle copied -> ${destArchive}`); +} + function runDistCommand(pluginNames) { - console.log(`Built-in plugin bundles need rebuilding: ${pluginNames.join(', ')}`); + const header = forceRebuild + ? `Forcing rebuild of built-in plugins: ${pluginNames.join(', ')}` + : `Built-in plugin bundles need rebuilding: ${pluginNames.join(', ')}`; + console.log(header); for (const pluginName of pluginNames) { const pluginDir = path.join(pluginSources, pluginName); + const packageJsonPath = path.join(pluginDir, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + console.warn(`Skipping non-code plugin ${pluginName} (no package.json).`); + continue; + } + ensurePluginDependencies(pluginDir); - console.log(`Packaging built-in plugin ${pluginName} via SDK CLI...`); - const res = spawnSync( - npmExecutable, - ['--prefix', sdkDir, 'run', 'openvcs', '--', 'dist', '--plugin-dir', pluginDir, '--out', pluginBundles], - { cwd: backendDir, stdio: 'inherit' } - ); + console.log(`Packaging built-in plugin ${pluginName} via npm run dist...`); + const res = spawnSync(npmExecutable, ['run', 'dist'], { + cwd: pluginDir, + stdio: 'inherit', + }); if (res.error) { - console.error(`Failed to run SDK packager for ${pluginName}:`, res.error); + console.error(`Failed to run npm dist for ${pluginName}:`, res.error); process.exit(res.status || 1); } if (res.status !== 0) { process.exit(res.status); } + + copyPackagedBundles(pluginName, pluginDir); } } @@ -194,9 +271,11 @@ ensureBundlesDir(); ensureNodeRuntimeDir(); ensureBundledNodeRuntime(); -const outdated = findOutdatedPlugins(); -if (outdated.length > 0) { - runDistCommand(outdated); +const targets = forceRebuild ? findAllPlugins() : findOutdatedPlugins(); +if (targets.length > 0) { + runDistCommand(targets); +} else if (forceRebuild) { + console.log('Force rebuild requested, but no built-in plugins were found.'); } else { console.log('Built-in plugin bundles are up to date.'); } diff --git a/Justfile b/Justfile index 9d8f079..ae8741b 100644 --- a/Justfile +++ b/Justfile @@ -7,7 +7,7 @@ build target="all": _build_all: _build_client _build_plugins: - cargo openvcs dist --all --plugin-dir Backend/built-in-plugins --out target/openvcs/built-in-plugins + node Backend/scripts/ensure-built-in-plugins.js _build_client: _build_plugins npm --prefix Frontend run build diff --git a/docs/plugins.md b/docs/plugins.md index 17e48c8..39936cc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -109,6 +109,22 @@ npx openvcs dist --plugin-dir /path/to/plugin --out /path/to/dist `openvcs dist` runs the build step automatically unless `--no-build` is passed. +## Bundling built-in plugins + +The app ships a handful of built-in plugins. Their `.ovcsp` bundles are rebuilt by +running the helper script from the repository root: + +```bash +node Backend/scripts/ensure-built-in-plugins.js +``` + +The script compares source timestamps against the previously packaged bundles, +installs npm deps if needed, runs `npm run dist` inside each plugin, and copies +the resulting archives into `target/openvcs/built-in-plugins`. Pass `--force` to +rebuild all built-in plugins regardless of timestamps (useful for CI or manual +repackaging). Non-code plugins missing `package.json` are skipped because they +ship prebuilt archives. + Typical Node plugin author modules now look like: ```ts diff --git a/scripts/tauri-build.js b/scripts/tauri-build.js index 04823ae..3139a36 100644 --- a/scripts/tauri-build.js +++ b/scripts/tauri-build.js @@ -3,6 +3,12 @@ const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); +/** + * Loads key/value pairs from a dotenv-style file into process.env. + * Existing environment values are preserved. + * + * @param {string} filePath - Path to the dotenv file. + */ function loadLocalEnv(filePath) { if (!fs.existsSync(filePath)) return; const content = fs.readFileSync(filePath, 'utf8'); @@ -23,6 +29,12 @@ function loadLocalEnv(filePath) { } } +/** + * Normalizes the Tauri signing key into the format expected by TAURI_SIGNING_PRIVATE_KEY. + * + * @param {string} raw - Raw key content. + * @returns {string} Normalized key value. + */ function normalizeSigningKey(raw) { let key = raw; // Tauri expects TAURI_SIGNING_PRIVATE_KEY to be base64 of the minisign key box text. @@ -38,6 +50,12 @@ function normalizeSigningKey(raw) { return key; } +/** + * Prompts for sensitive input without echoing typed characters. + * + * @param {string} question - Prompt text. + * @returns {Promise} User-provided value. + */ function promptHidden(question) { return new Promise((resolve) => { const stdin = process.stdin; @@ -66,8 +84,26 @@ function promptHidden(question) { }); } +/** + * Returns repository and Backend directories based on this script location. + * + * @returns {{repoRoot: string, backendDir: string}} Build directory paths. + */ +function resolvePaths() { + const cwdRepoRoot = process.cwd(); + const cwdBackendDir = path.join(cwdRepoRoot, 'Backend'); + const cwdTauriConfig = path.join(cwdBackendDir, 'tauri.conf.json'); + if (fs.existsSync(cwdTauriConfig)) { + return { repoRoot: cwdRepoRoot, backendDir: cwdBackendDir }; + } + + const scriptRepoRoot = path.resolve(__dirname, '..'); + const scriptBackendDir = path.join(scriptRepoRoot, 'Backend'); + return { repoRoot: scriptRepoRoot, backendDir: scriptBackendDir }; +} + async function main() { - const repoRoot = process.cwd(); + const { repoRoot, backendDir } = resolvePaths(); loadLocalEnv(path.join(repoRoot, '.env.tauri.local')); if (process.env.TAURI_SIGNING_PRIVATE_KEY_FILE && !process.env.TAURI_SIGNING_PRIVATE_KEY) { @@ -90,6 +126,7 @@ async function main() { process.env.NO_STRIP = process.env.NO_STRIP || 'true'; const child = spawn('cargo', ['tauri', 'build'], { + cwd: backendDir, stdio: 'inherit', env: process.env, });