donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
389 lines • 16.9 kB
JavaScript
;
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