gingee-cli
Version:
The Gingee Command Line Interface (CLI), official command line tool for creating and managing Gingee projects.
431 lines (375 loc) • 18.6 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const yauzl = require('yauzl');
const archiver = require('archiver');
const { URL } = require('url'); // Using Node's built-in URL parser
const PERMISSION_DESCRIPTIONS = {
"cache": "Allows the app to use the caching service for storing and retrieving data.",
"db": "Allows the app to connect to and query the database(s) you configure for it.",
"fs": "Grants full read/write access within the app's own secure directories (`box` and `web`).",
"httpclient": "Permits the app to make outbound network requests to any external API or website.",
"platform": "PRIVILEGED: Allows managing the lifecycle of other applications on the server. Grant with extreme caution.",
"pdf": "Allows the app to generate and manipulate PDF documents.",
"zip": "Allows the app to create and extract ZIP archives.",
"image": "Allows the app to manipulate image files."
};
/**
* Reads the application preset file from the specified path.
* @param {string} filePath - The path to the preset file.
* @returns {object} The contents of the preset file.
* @throws {Error} If the preset file is not found or cannot be read.
*/
function _readAppPreset(filePath) {
const absolutePath = path.resolve(process.cwd(), filePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Preset file not found at: ${absolutePath}`);
}
return fs.readJsonSync(absolutePath);
}
/**
* Validates the application preset file.
* @param {object} preset - The contents of the preset file.
* @param {string} action - The action being performed (e.g., "install", "upgrade").
* @throws {Error} If the preset file is invalid.
*/
function _validateAppPreset(preset, action) {
if (!preset[action]) {
throw new Error(`Preset file is missing the required top-level key for the "${action}" action.`);
}
switch (action) {
case 'install':
case 'upgrade':
if (!preset[action].consent || !Array.isArray(preset[action].consent.grantPermissions)) {
throw new Error(`Preset for "${action}" is missing the required "consent.grantPermissions" array.`);
}
break;
case 'rollback':
if (!preset[action].consent || !Array.isArray(preset[action].consent.grantPermissions)) {
throw new Error(`Preset for "rollback" is missing the required "consent.grantPermissions" array.`);
}
break;
case 'delete':
if (preset[action].confirm !== true) {
throw new Error(`Preset for "delete" requires the "confirm" key to be explicitly set to true.`);
}
break;
}
}
/**
* Substitutes environment variables in the database configuration.
* @param {Array} dbConfigs - The database configuration objects.
* @returns {Array} The updated database configuration objects with environment variables substituted.
*/
function _substituteEnvVars(dbConfigs) {
if (!dbConfigs) return [];
// Deep clone to avoid modifying the original object
const configs = JSON.parse(JSON.stringify(dbConfigs));
for (const config of configs) {
for (const key in config) {
const value = config[key];
if (typeof value === 'string' && value.startsWith('$')) {
const envVarName = value.substring(1);
const envVarValue = process.env[envVarName];
if (envVarValue === undefined) {
throw new Error(`Environment variable "${envVarName}" specified in the preset file is not set.`);
}
config[key] = envVarValue;
}
}
}
return configs;
}
/**
* Resolves the user-provided URL to the final manifest URL.
* @param {string} inputUrl The URL provided by the user.
* @returns {string} The final, correct URL for the gstore.json file.
* @throws {Error} If the URL is invalid or points to an incorrect filename.
* @private
*/
function _resolveStoreUrl(inputUrl) {
const parsedUrl = new URL(inputUrl);
const pathname = parsedUrl.pathname;
const pathSegments = pathname.split('/');
const lastSegment = pathSegments[pathSegments.length - 1];
if (lastSegment.includes('.')) {
if (lastSegment !== 'gstore.json') {
throw new Error(`Invalid manifest filename. If a filename is specified, it must be 'gstore.json'.`);
}
return inputUrl;
} else {
parsedUrl.pathname = path.join(pathname, 'gstore.json');
return parsedUrl.toString();
}
}
/**
* Resolves the .gin package download URL, handling both absolute and relative paths.
* @param {string} storeUrl The absolute URL of the gstore.json manifest.
* @param {string} downloadUrl The download_url value from the manifest.
* @returns {string} The final, absolute URL for the .gin package.
* @private
*/
function _resolveDownloadUrl(storeUrl, downloadUrl) {
// Check if downloadUrl is already an absolute URL.
if (downloadUrl.startsWith('http://') || downloadUrl.startsWith('https://')) {
return downloadUrl;
}
// If it's relative, resolve it against the store's base URL.
const storeBaseUrl = new URL('.', storeUrl).toString();
return new URL(downloadUrl, storeBaseUrl).toString();
}
function _getHttpClientErrorMessage(err) {
let message = '';
if (err.response) {
if(err.response.status === 401)
message = 'Unauthorized: Please login using the login command';
else
message = `${err.response.status}: ${err.response.data.message || 'Server error.'}`;
} else if (err.request) {
message = 'Network Error: No response received. Check if server is running.';
} else {
message = `Error: ${err.message}`;
}
return message;
}
/**
* Securely unzips a buffer to an absolute destination path.
* This is a self-contained utility for the CLI.
* @private
*/
async function _unzipBuffer(zipBuffer, destAbsolutePath) {
fs.mkdirSync(destAbsolutePath, { recursive: true });
const zipfile = await new Promise((resolve, reject) => {
yauzl.fromBuffer(zipBuffer, { lazyEntries: true }, (err, zf) => err ? reject(err) : resolve(zf));
});
await new Promise((resolve, reject) => {
zipfile.on('error', reject);
zipfile.on('end', resolve);
zipfile.on('entry', (entry) => {
const finalDestPath = path.join(destAbsolutePath, entry.fileName);
const resolvedPath = path.resolve(finalDestPath);
if (!resolvedPath.startsWith(destAbsolutePath)) {
return reject(new Error(`Security Error: Zip file contains path traversal ('${entry.fileName}').`));
}
if (/\/$/.test(entry.fileName)) {
fs.mkdirSync(resolvedPath, { recursive: true });
zipfile.readEntry();
} else {
zipfile.openReadStream(entry, (err, readStream) => {
if (err) return reject(err);
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
const writeStream = fs.createWriteStream(resolvedPath);
readStream.pipe(writeStream).on('finish', () => zipfile.readEntry()).on('error', reject);
});
}
});
zipfile.readEntry();
});
}
async function _repackApp(sourcePath) {
const archive = archiver('zip', { zlib: { level: 9 } });
const buffers = [];
archive.on('data', buffer => buffers.push(buffer));
const streamPromise = new Promise((resolve, reject) => {
archive.on('end', () => resolve(Buffer.concat(buffers)));
archive.on('error', reject);
});
archive.directory(sourcePath, false);
await archive.finalize();
return streamPromise;
}
async function _getPermissions(unpackedPath) {
const { default: chalk } = await import('chalk');
const { default: inquirer } = await import('inquirer');
const pmftPath = path.join(unpackedPath, 'box', 'pmft.json');
if (!fs.existsSync(pmftPath)) {
throw new Error('Package is invalid: Missing permissions manifest (pmft.json).');
}
const pmft = await fs.readJson(pmftPath);
const appJson = await fs.readJson(path.join(unpackedPath, 'box', 'app.json'));
console.log(`\n--- Application Details ---`);
console.log(chalk.blueBright(` App: ${appJson.name} (v${appJson.version})`));
console.log(chalk.blueBright(` Description: ${appJson.description}\n`));
console.log(chalk.yellow('This application requests the following permissions:'));
const mandatoryPerms = pmft.permissions.mandatory || [];
const optionalPerms = pmft.permissions.optional || [];
console.log(chalk.bgRedBright('\nMandatory Permissions:'));
mandatoryPerms.forEach(p => console.log(`- ${chalk.bold(p)}: ${PERMISSION_DESCRIPTIONS[p]}`));
const { consent } = await inquirer.prompt([{ type: 'confirm', name: 'consent', message: '\nDo you grant the mandatory permissions?', default: false }]);
if (!consent) throw new Error('Installation cancelled by user.');
console.log(chalk.bgYellow('\nOptional Permissions:'));
optionalPerms.forEach(p => console.log(`- ${p}: ${PERMISSION_DESCRIPTIONS[p]}`));
let grantedPermissions = [...mandatoryPerms];
if (optionalPerms.length > 0) {
const { chosenOptionals } = await inquirer.prompt([{ type: 'checkbox', name: 'chosenOptionals', message: '\nSelect any optional permissions to grant:', choices: optionalPerms }]);
grantedPermissions.push(...chosenOptionals);
}
return grantedPermissions;
}
async function _getDbRequirements(unpackedPath) {
const { default: chalk } = await import('chalk');
const { default: inquirer } = await import('inquirer');
// Step 1: Read the app.json from the unpacked package.
const appJson = await fs.readJson(path.join(unpackedPath, 'box', 'app.json'));
const dbConnections = appJson.db || [];
if (dbConnections.length === 0) {
return [];
}
console.log('\n--- Configuring Database Requirements ---');
const newDbConfigs = [];
// Step 2: Loop through each database connection defined in the app.json
for (const dbReq of dbConnections) {
console.log(chalk.cyan(`\nThis app's app.json requests a '${dbReq.type}' database connection named '${dbReq.name}'.`));
console.log(chalk.cyan(`Please provide or confirm the following details:`));
const questions = [];
const keysToPrompt = Object.keys(dbReq);
// Step 3: Dynamically generate prompts based on the keys in the app.json db object.
for (const key of keysToPrompt) {
// We don't prompt for these structural keys.
if (key === 'type' || key === 'name') {
continue;
}
const question = {
name: key,
message: `${key}:`,
// Use the value from app.json as the default.
default: dbReq[key]
};
// Special handling for the password key.
if (key === 'password') {
question.type = 'password';
question.mask = '*';
// CRITICAL: Never use a placeholder/default for a password prompt.
delete question.default;
}
// Special handling for sqlite to provide a more descriptive message.
if (key === 'database' && dbReq.type === 'sqlite') {
question.message = 'Database File Path (relative to the app\'s `box` folder):';
}
questions.push(question);
}
if (questions.length > 0) {
const answers = await inquirer.prompt(questions);
// Merge the user's answers over the original config from app.json
newDbConfigs.push({ ...dbReq, ...answers });
} else {
// If there were no keys to prompt for, pass the original config through.
newDbConfigs.push(dbReq);
}
}
return newDbConfigs;
}
async function _getUpgradePermissions(unpackedPath, currentPermissions) {
const { default: chalk } = await import('chalk');
const { default: inquirer } = await import('inquirer');
const pmftPath = path.join(unpackedPath, 'box', 'pmft.json');
if (!fs.existsSync(pmftPath)) {
throw new Error('New package is invalid: Missing permissions manifest (pmft.json).');
}
const pmft = await fs.readJson(pmftPath);
const appJson = await fs.readJson(path.join(unpackedPath, 'box', 'app.json'));
console.log(`\n--- Upgrading Application ---`);
console.log(chalk.blueBright(` App: ${appJson.name} (v${appJson.version})`));
console.log(chalk.yellow('\nReview the following permission changes:'));
const newMandatorySet = new Set(pmft.permissions.mandatory || []);
const newOptionalSet = new Set(pmft.permissions.optional || []);
const currentGrantedSet = new Set(currentPermissions);
const allNewRequestedSet = new Set([...newMandatorySet, ...newOptionalSet]);
const newlyRequestedMandatory = [...newMandatorySet].filter(p => !currentGrantedSet.has(p));
const newlyRequestedOptional = [...newOptionalSet].filter(p => !currentGrantedSet.has(p));
const permissionsToRevoke = [...currentGrantedSet].filter(p => !allNewRequestedSet.has(p));
const unchangedPermissions = [...currentGrantedSet].filter(p => allNewRequestedSet.has(p));
const noChanges = newlyRequestedMandatory.length === 0 && newlyRequestedOptional.length === 0 && permissionsToRevoke.length === 0;
if (noChanges) {
console.log(chalk.blueBright('- No permission changes are required for this upgrade.'));
return currentPermissions; // Return the existing permissions as is.
}
newlyRequestedMandatory.forEach(p => console.log(chalk.blueBright(`+ GRANT (Mandatory): ${p} - ${PERMISSION_DESCRIPTIONS[p]}`)));
newlyRequestedOptional.forEach(p => console.log(chalk.blueBright(`+ GRANT (Optional): ${p} - ${PERMISSION_DESCRIPTIONS[p]}`)));
permissionsToRevoke.forEach(p => console.log(chalk.blueBright(`- REVOKE (No longer requested): ${p}`)));
if (newlyRequestedMandatory.length > 0) {
const { mandatoryConsent } = await inquirer.prompt([{
type: 'confirm',
name: 'mandatoryConsent',
message: `This upgrade requires new MANDATORY permissions. Do you approve granting them?`,
default: false
}]);
if (!mandatoryConsent) throw new Error('Mandatory permissions denied. Upgrade cancelled by user.');
}
let chosenOptionals = [];
if (newlyRequestedOptional.length > 0) {
const { chosen } = await inquirer.prompt([{
type: 'checkbox',
name: 'chosen',
message: 'Please select which new OPTIONAL permissions you wish to grant:',
choices: newlyRequestedOptional
}]);
chosenOptionals = chosen;
}
const finalPermissions = new Set([
...unchangedPermissions,
...newlyRequestedMandatory,
...chosenOptionals
]);
console.log(chalk.blueBright('\nPermissions confirmed.'));
return Array.from(finalPermissions);
}
async function _getRollbackPermissions(backupPermissions, currentPermissions) {
const { default: chalk } = await import('chalk');
const { default: inquirer } = await import('inquirer');
console.log(chalk.yellow('\nPlease review the following permission changes for the rollback:'));
const backupMandatorySet = new Set(backupPermissions.mandatory || []);
const backupOptionalSet = new Set(backupPermissions.optional || []);
const currentGrantedSet = new Set(currentPermissions);
const allBackupRequestedSet = new Set([...backupMandatorySet, ...backupOptionalSet]);
const toRevoke = [...currentGrantedSet].filter(p => !allBackupRequestedSet.has(p));
const toGrantMandatory = [...backupMandatorySet].filter(p => !currentGrantedSet.has(p));
const toGrantOptional = [...backupOptionalSet].filter(p => !currentGrantedSet.has(p));
const noChanges = toRevoke.length === 0 && toGrantMandatory.length === 0 && toGrantOptional.length === 0;
if (noChanges) {
console.log(chalk.bgBlueBright('- No permission changes are required for this rollback.'));
return currentPermissions;
}
toGrantMandatory.forEach(p => console.log(chalk.blueBright(`+ GRANT (Mandatory): ${p} - ${PERMISSION_DESCRIPTIONS[p]}`)));
toGrantOptional.forEach(p => console.log(chalk.blueBright(`+ GRANT (Optional): ${p} - ${PERMISSION_DESCRIPTIONS[p]}`)));
toRevoke.forEach(p => console.log(chalk.blueBright(`- REVOKE (No longer requested): ${p}`)));
if (toGrantMandatory.length > 0) {
const { mandatoryConsent } = await inquirer.prompt([{
type: 'confirm',
name: 'mandatoryConsent',
message: `This rollback requires granting new MANDATORY permissions. Do you approve?`,
default: false
}]);
if (!mandatoryConsent) throw new Error('Mandatory permissions denied. Rollback cancelled by user.');
}
let chosenOptionals = [];
if (toGrantOptional.length > 0) {
const { chosen } = await inquirer.prompt([{
type: 'checkbox',
name: 'chosen',
message: 'Please select which new OPTIONAL permissions you wish to grant for the rolled-back version:',
choices: toGrantOptional
}]);
chosenOptionals = chosen;
}
const finalPermissions = new Set([
...[...currentGrantedSet].filter(p => allBackupRequestedSet.has(p)),
...toGrantMandatory,
...chosenOptionals
]);
console.log(chalk.blueBright('\nPermissions confirmed.'));
return Array.from(finalPermissions);
}
module.exports = {
_readAppPreset,
_validateAppPreset,
_substituteEnvVars,
_unzipBuffer,
_repackApp,
_getPermissions,
_getUpgradePermissions,
_getRollbackPermissions,
_getDbRequirements,
_resolveStoreUrl,
_resolveDownloadUrl,
_getHttpClientErrorMessage
};