UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

389 lines 16.9 kB
#!/usr/bin/env node "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.getPluginName = getPluginName; exports.buildPlugin = buildPlugin; exports.installPlugin = installPlugin; exports.parseReleaseSpec = parseReleaseSpec; exports.installFromRelease = installFromRelease; exports.parseArgs = parseArgs; exports.main = main; /** * @fileoverview Donobu Plugin Installation CLI * * A command-line utility for installing Donobu plugins into the local Donobu * Studio environment. Two modes: * * 1. **Local build-and-copy** (default): run from a plugin source directory * with `package.json` and `src/`. Builds via `npm run build` and copies * `dist/` into Donobu's plugins directory. * * 2. **Release fetch** (`--from-release`): downloads a pre-built first-party * plugin bundle from the Donobu API (which checks the customer's * account entitlement before serving bytes from the private release * registry), verifies the sha256, and extracts it into Donobu's plugins * directory. No local project required. * * @usage * ```bash * # Local mode * npm exec install-donobu-plugin * * # Release mode (latest) * DONOBU_API_KEY=DB_… npx install-donobu-plugin \ * --from-release @donobu/donobu-mobile * * # Release mode (pinned version) * DONOBU_API_KEY=DB_… npx install-donobu-plugin \ * --from-release @donobu/donobu-mobile@1.0.0 * ``` */ const child_process_1 = require("child_process"); const crypto_1 = require("crypto"); const fs_1 = require("fs"); const promises_1 = require("fs/promises"); const os_1 = require("os"); const path_1 = require("path"); const promises_2 = require("stream/promises"); const util_1 = require("util"); const Logger_1 = require("../utils/Logger"); const MiscUtils_1 = require("../utils/MiscUtils"); const execAsync = (0, util_1.promisify)(child_process_1.exec); /** * Default Donobu API base URL. Matches the default in `envVars.ts` so that a * customer with a production `DONOBU_API_KEY` needs no extra configuration * to install plugins. Override for staging via `DONOBU_API_BASE_URL`. */ const DEFAULT_DONOBU_API_BASE_URL = 'https://donobu-prd-api-service-73193699649.us-central1.run.app'; async function getPluginName() { try { // First, try to read the plugin name from package.json const packageJsonContent = await (0, promises_1.readFile)('package.json', 'utf8'); const packageJson = JSON.parse(packageJsonContent); if (packageJson.name) { Logger_1.appLogger.info(`Using plugin name from package.json: ${packageJson.name}`); return packageJson.name; } } catch (error) { Logger_1.appLogger.warn('Could not read package.json name, falling back to git/user detection', error); } // Fallback to a heuristic try { const { stdout } = await execAsync('git rev-parse --show-toplevel'); const repoPath = stdout.trim(); const gitBasedName = `${(0, path_1.basename)(repoPath).replace(/\.git$/, '')}-custom-tools`; Logger_1.appLogger.info(`Using git-based plugin name: ${gitBasedName}`); return gitBasedName; } catch { const user = process.env.USER || 'unknown-user'; const userBasedName = `${user}-custom-tools`; Logger_1.appLogger.info(`Using user-based plugin name: ${userBasedName}`); return userBasedName; } } async function buildPlugin() { Logger_1.appLogger.info('Building plugin...'); await execAsync('npm run build'); } async function installPlugin() { const pluginName = await getPluginName(); const pluginsDir = (0, path_1.join)(MiscUtils_1.MiscUtils.baseWorkingDirectory(), 'plugins'); const thisPluginDir = (0, path_1.join)(pluginsDir, pluginName); Logger_1.appLogger.info(`Installing plugin: ${pluginName}`); // Create plugins directory await (0, promises_1.mkdir)(pluginsDir, { recursive: true }); // Remove old plugin if exists try { await (0, promises_1.rm)(thisPluginDir, { recursive: true, force: true }); } catch { // Ignore if directory doesn't exist } // Copy built plugin await (0, promises_1.cp)('dist', thisPluginDir, { recursive: true }); Logger_1.appLogger.info(`Plugin installed successfully to: ${thisPluginDir}`); Logger_1.appLogger.info('Restart Donobu to load the plugin.'); } /** * Parses `name`, `name@version`, `@scope/name`, or `@scope/name@version`. * The last `@` after the first character (if any) separates name and version. */ function parseReleaseSpec(spec) { if (!spec) { throw new Error('--from-release requires a plugin name'); } const atIdx = spec.lastIndexOf('@'); if (atIdx > 0) { return { name: spec.slice(0, atIdx), version: spec.slice(atIdx + 1) }; } return { name: spec, version: undefined }; } function getDonobuApiBaseUrl() { return (process.env.DONOBU_API_BASE_URL?.replace(/\/$/, '') ?? DEFAULT_DONOBU_API_BASE_URL); } function getDonobuApiKey(explicit) { const key = explicit ?? process.env.DONOBU_API_KEY; if (!key) { throw new Error('Missing DONOBU_API_KEY. Pass --api-key <key>, set DONOBU_API_KEY in ' + 'your environment, or retrieve a key at https://donobu.com/account/keys.'); } return key; } /** * Surface donobu-api JSON error bodies (`{ error, message }`) in the CLI's * error output, so customers see the server's description instead of a bare * HTTP status code. */ async function extractApiErrorMessage(res) { try { const body = (await res.clone().json()); if (body.message) { return body.message; } if (body.error) { return body.error; } } catch { /* fall through */ } return `${res.status} ${res.statusText}`; } async function fetchReleaseMetadata(baseUrl, apiKey, spec) { const params = new URLSearchParams({ name: spec.name, version: spec.version ?? 'latest', }); const url = `${baseUrl}/v1/plugins/release?${params.toString()}`; const res = await fetch(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}`, 'User-Agent': 'install-donobu-plugin', }, }); if (!res.ok) { throw new Error(`Failed to resolve ${spec.name}@${spec.version ?? 'latest'}: ` + (await extractApiErrorMessage(res))); } return (await res.json()); } async function downloadTarball(baseUrl, apiKey, name, version, destPath) { const params = new URLSearchParams({ name, version }); const url = `${baseUrl}/v1/plugins/release/tarball?${params.toString()}`; const res = await fetch(url, { headers: { Accept: 'application/octet-stream', Authorization: `Bearer ${apiKey}`, 'User-Agent': 'install-donobu-plugin', }, }); if (!res.ok || !res.body) { throw new Error(`Failed to download ${name}@${version}: ` + (await extractApiErrorMessage(res))); } await (0, promises_2.pipeline)(res.body, (0, fs_1.createWriteStream)(destPath)); } async function fetchEntitlementsEnvelope(baseUrl, apiKey) { const res = await fetch(`${baseUrl}/v1/entitlements`, { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}`, 'User-Agent': 'install-donobu-plugin', }, }); if (!res.ok) { throw new Error(`Failed to fetch entitlements: ` + (await extractApiErrorMessage(res))); } // Persist the exact JSON bytes the server sent. Reserializing would risk // reordering keys in a way that invalidates the signature downstream. const raw = await res.text(); return { raw, envelope: JSON.parse(raw) }; } async function sha256OfFile(path) { const hash = (0, crypto_1.createHash)('sha256'); const buf = await (0, promises_1.readFile)(path); hash.update(buf); return hash.digest('hex'); } async function extractTarball(tarballPath, destDir) { await (0, promises_1.mkdir)(destDir, { recursive: true }); // `tar` handles both gzip and xz transparently with `-xf`. `-C` sets the // extraction root. Expect the tarball to contain the flat plugin payload // (index.mjs, package.json, etc.) at its root — not nested under dist/. await execAsync(`tar -xf "${tarballPath}" -C "${destDir}"`); } /** * Filename for the signed entitlement envelope when colocated with a * plugin's extracted bundle. Matches the constant in the plugin's * entitlement-check code; changing one without the other breaks license * discovery. */ const LICENSE_FILENAME = '.donobu-license.json'; /** * Resolve the parent directory into which the plugin's own dir will be * placed. Without `--into`, we use the default `<app-data>/plugins/` * location the Donobu Studio plugin loader scans. With `--into`, we * honor the caller's path (useful for code-based test setups that want * the plugin discoverable inside their own `node_modules/@donobu/`). */ function resolveInstallParentDir(into) { if (into) { return (0, path_1.resolve)(into); } return (0, path_1.join)(MiscUtils_1.MiscUtils.baseWorkingDirectory(), 'plugins'); } async function installFromRelease(spec, apiKey, into) { const baseUrl = getDonobuApiBaseUrl(); Logger_1.appLogger.info(`Resolving ${spec.name}@${spec.version ?? 'latest'} from ${baseUrl}...`); const { name, version, manifest } = await fetchReleaseMetadata(baseUrl, apiKey, spec); if (manifest.name !== name || manifest.version !== version) { throw new Error(`Manifest mismatch: expected ${name}@${version}, got ${manifest.name}@${manifest.version}.`); } // Fetch the signed entitlement envelope before the tarball download so we // fail fast if the server would not recognize the grant anyway. The // metadata call above already gates on entitlement, but this also catches // races (e.g. revocation between the two calls) and gives us the signed // payload gated plugins require at load time. Logger_1.appLogger.info('Fetching signed entitlement envelope...'); const { raw: envelopeJson, envelope } = await fetchEntitlementsEnvelope(baseUrl, apiKey); if (!envelope.payload.entitlements[name]) { throw new Error(`Entitlement envelope does not contain a grant for ${name}. ` + `The grant may have been revoked during install; try again.`); } const workDir = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'donobu-plugin-')); try { const tarPath = (0, path_1.join)(workDir, manifest.tarball); Logger_1.appLogger.info(`Downloading ${manifest.tarball}...`); await downloadTarball(baseUrl, apiKey, name, version, tarPath); const actualSha = await sha256OfFile(tarPath); if (actualSha.toLowerCase() !== manifest.sha256.toLowerCase()) { throw new Error(`Checksum mismatch for ${manifest.tarball} — refusing to install. ` + `Expected ${manifest.sha256}, got ${actualSha}.`); } // Extract into a staging dir inside workDir first, then atomically swap // into the final plugins location to avoid half-written plugin state. // License file goes inside the staging dir before the swap so the // post-rename plugin dir always has both bundle + license present at // the same instant — no half-installed window. const stageDir = (0, path_1.join)(workDir, 'stage'); await extractTarball(tarPath, stageDir); await (0, promises_1.writeFile)((0, path_1.join)(stageDir, LICENSE_FILENAME), envelopeJson + '\n', 'utf8'); const parentDir = resolveInstallParentDir(into); const destDir = (0, path_1.join)(parentDir, name); await (0, promises_1.mkdir)(parentDir, { recursive: true }); // For scoped names like @donobu/donobu-mobile, ensure the @scope parent exists. await (0, promises_1.mkdir)((0, path_1.join)(destDir, '..'), { recursive: true }); await (0, promises_1.rm)(destDir, { recursive: true, force: true }); await (0, promises_1.rename)(stageDir, destDir); const grantExpiresAt = envelope.payload.entitlements[name].expiresAt; const expiryDescription = grantExpiresAt === null ? 'perpetual (never expires)' : grantExpiresAt; Logger_1.appLogger.info(`Installed ${name}@${version} to ${destDir} ` + `(license colocated, entitlement ${expiryDescription}). ` + `Restart Donobu to load the plugin.`); // Soft-warn when the customer is installing very close to the grant's // expiry — catches the "I installed today but it already stopped working" // footgun. if (grantExpiresAt !== null) { const daysUntilExpiry = Math.floor((new Date(grantExpiresAt).getTime() - Date.now()) / 86_400_000); if (daysUntilExpiry >= 0 && daysUntilExpiry < 7) { Logger_1.appLogger.warn(`Your entitlement expires in ${daysUntilExpiry} day(s) ` + `(${grantExpiresAt}). Contact support@donobu.com to renew before relying on this install.`); } } } finally { await (0, promises_1.rm)(workDir, { recursive: true, force: true }); } } function parseArgs(argv) { const args = argv.slice(2); let fromRelease; let apiKey; let into; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '--from-release' && i + 1 < args.length) { fromRelease = args[++i]; } else if (a.startsWith('--from-release=')) { fromRelease = a.slice('--from-release='.length); } else if (a === '--api-key' && i + 1 < args.length) { apiKey = args[++i]; } else if (a.startsWith('--api-key=')) { apiKey = a.slice('--api-key='.length); } else if (a === '--into' && i + 1 < args.length) { into = args[++i]; } else if (a.startsWith('--into=')) { into = a.slice('--into='.length); } else if (a === '--help' || a === '-h') { printUsage(); process.exit(0); } } return { fromRelease, apiKey, into }; } function printUsage() { console.log([ 'Usage:', ' install-donobu-plugin # build & install from current dir', ' install-donobu-plugin --from-release <name>[@<version>] [--api-key <key>] [--into <dir>]', '', 'Release-mode options:', ' --from-release <name>[@<version>] Install a pre-built Donobu plugin.', ' Omit @version to install the latest.', ' --api-key <key> Donobu API key (or set DONOBU_API_KEY).', ' Your account entitlement gates access.', ' --into <dir> Install the plugin under <dir>/<name>/', ' instead of the Donobu Studio plugins', ' directory. Useful for code-based test', ' setups — pass `./node_modules` to make', ' the plugin importable from your project.', '', 'Examples:', ' install-donobu-plugin --from-release @donobu/donobu-mobile', ' install-donobu-plugin --from-release @donobu/donobu-mobile@1.0.0', ' install-donobu-plugin --from-release @donobu/donobu-mobile --into ./node_modules', ].join('\n')); } async function main() { const { fromRelease, apiKey, into } = parseArgs(process.argv); try { if (fromRelease) { await installFromRelease(parseReleaseSpec(fromRelease), getDonobuApiKey(apiKey), into); return; } // Local build-and-copy mode — must be run from a plugin source directory. try { await (0, promises_1.access)('package.json', fs_1.constants.F_OK); await (0, promises_1.access)('src', fs_1.constants.F_OK); } catch { Logger_1.appLogger.error('Error: This command must be run from a Donobu plugin directory'); Logger_1.appLogger.error('Make sure you have package.json and src/ directory'); process.exit(1); } await buildPlugin(); await installPlugin(); } catch (error) { Logger_1.appLogger.error('Installation failed:', error); process.exit(1); } } // If this file is being run directly if (require.main === module) { main().catch((error) => { console.error('Unhandled error:', error); process.exit(1); }); } //# sourceMappingURL=install-donobu-plugin.js.map