gingee-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/commands/addApp.js +126 -0
- package/commands/addScript.js +45 -0
- package/commands/apiClient.js +139 -0
- package/commands/deleteApp.js +62 -0
- package/commands/init.js +133 -0
- package/commands/installApp.js +85 -0
- package/commands/installStoreApp.js +87 -0
- package/commands/installerUtils.js +430 -0
- package/commands/listApps.js +41 -0
- package/commands/listBackups.js +41 -0
- package/commands/listStoreApps.js +60 -0
- package/commands/login.js +70 -0
- package/commands/logout.js +10 -0
- package/commands/packageApp.js +66 -0
- package/commands/resetGlade.js +95 -0
- package/commands/resetPwd.js +80 -0
- package/commands/rollbackApp.js +63 -0
- package/commands/service.js +123 -0
- package/commands/upgradeApp.js +80 -0
- package/commands/upgradeStoreApp.js +97 -0
- package/commands/utils.js +35 -0
- package/index.js +154 -0
- package/package.json +51 -0
- package/templates/project/ecosystem.config.js +34 -0
- package/templates/project/gingee.json +34 -0
- package/templates/project/package.json +15 -0
- package/templates/project/start.js +6 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const yauzl = require('yauzl');
|
|
4
|
+
const archiver = require('archiver');
|
|
5
|
+
const { URL } = require('url'); // Using Node's built-in URL parser
|
|
6
|
+
|
|
7
|
+
const PERMISSION_DESCRIPTIONS = {
|
|
8
|
+
"cache": "Allows the app to use the caching service for storing and retrieving data.",
|
|
9
|
+
"db": "Allows the app to connect to and query the database(s) you configure for it.",
|
|
10
|
+
"fs": "Grants full read/write access within the app's own secure directories (`box` and `web`).",
|
|
11
|
+
"httpclient": "Permits the app to make outbound network requests to any external API or website.",
|
|
12
|
+
"platform": "PRIVILEGED: Allows managing the lifecycle of other applications on the server. Grant with extreme caution.",
|
|
13
|
+
"pdf": "Allows the app to generate and manipulate PDF documents.",
|
|
14
|
+
"zip": "Allows the app to create and extract ZIP archives.",
|
|
15
|
+
"image": "Allows the app to manipulate image files."
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads the application preset file from the specified path.
|
|
20
|
+
* @param {string} filePath - The path to the preset file.
|
|
21
|
+
* @returns {object} The contents of the preset file.
|
|
22
|
+
* @throws {Error} If the preset file is not found or cannot be read.
|
|
23
|
+
*/
|
|
24
|
+
function _readAppPreset(filePath) {
|
|
25
|
+
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
26
|
+
if (!fs.existsSync(absolutePath)) {
|
|
27
|
+
throw new Error(`Preset file not found at: ${absolutePath}`);
|
|
28
|
+
}
|
|
29
|
+
return fs.readJsonSync(absolutePath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validates the application preset file.
|
|
34
|
+
* @param {object} preset - The contents of the preset file.
|
|
35
|
+
* @param {string} action - The action being performed (e.g., "install", "upgrade").
|
|
36
|
+
* @throws {Error} If the preset file is invalid.
|
|
37
|
+
*/
|
|
38
|
+
function _validateAppPreset(preset, action) {
|
|
39
|
+
if (!preset[action]) {
|
|
40
|
+
throw new Error(`Preset file is missing the required top-level key for the "${action}" action.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
switch (action) {
|
|
44
|
+
case 'install':
|
|
45
|
+
case 'upgrade':
|
|
46
|
+
if (!preset[action].consent || !Array.isArray(preset[action].consent.grantPermissions)) {
|
|
47
|
+
throw new Error(`Preset for "${action}" is missing the required "consent.grantPermissions" array.`);
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
case 'rollback':
|
|
51
|
+
if (!preset[action].consent || !Array.isArray(preset[action].consent.grantPermissions)) {
|
|
52
|
+
throw new Error(`Preset for "rollback" is missing the required "consent.grantPermissions" array.`);
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
case 'delete':
|
|
56
|
+
if (preset[action].confirm !== true) {
|
|
57
|
+
throw new Error(`Preset for "delete" requires the "confirm" key to be explicitly set to true.`);
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Substitutes environment variables in the database configuration.
|
|
65
|
+
* @param {Array} dbConfigs - The database configuration objects.
|
|
66
|
+
* @returns {Array} The updated database configuration objects with environment variables substituted.
|
|
67
|
+
*/
|
|
68
|
+
function _substituteEnvVars(dbConfigs) {
|
|
69
|
+
if (!dbConfigs) return [];
|
|
70
|
+
// Deep clone to avoid modifying the original object
|
|
71
|
+
const configs = JSON.parse(JSON.stringify(dbConfigs));
|
|
72
|
+
for (const config of configs) {
|
|
73
|
+
for (const key in config) {
|
|
74
|
+
const value = config[key];
|
|
75
|
+
if (typeof value === 'string' && value.startsWith('$')) {
|
|
76
|
+
const envVarName = value.substring(1);
|
|
77
|
+
const envVarValue = process.env[envVarName];
|
|
78
|
+
if (envVarValue === undefined) {
|
|
79
|
+
throw new Error(`Environment variable "${envVarName}" specified in the preset file is not set.`);
|
|
80
|
+
}
|
|
81
|
+
config[key] = envVarValue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return configs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolves the user-provided URL to the final manifest URL.
|
|
90
|
+
* @param {string} inputUrl The URL provided by the user.
|
|
91
|
+
* @returns {string} The final, correct URL for the gstore.json file.
|
|
92
|
+
* @throws {Error} If the URL is invalid or points to an incorrect filename.
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
function _resolveStoreUrl(inputUrl) {
|
|
96
|
+
const parsedUrl = new URL(inputUrl);
|
|
97
|
+
const pathname = parsedUrl.pathname;
|
|
98
|
+
const pathSegments = pathname.split('/');
|
|
99
|
+
const lastSegment = pathSegments[pathSegments.length - 1];
|
|
100
|
+
|
|
101
|
+
if (lastSegment.includes('.')) {
|
|
102
|
+
if (lastSegment !== 'gstore.json') {
|
|
103
|
+
throw new Error(`Invalid manifest filename. If a filename is specified, it must be 'gstore.json'.`);
|
|
104
|
+
}
|
|
105
|
+
return inputUrl;
|
|
106
|
+
} else {
|
|
107
|
+
parsedUrl.pathname = path.join(pathname, 'gstore.json');
|
|
108
|
+
return parsedUrl.toString();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolves the .gin package download URL, handling both absolute and relative paths.
|
|
114
|
+
* @param {string} storeUrl The absolute URL of the gstore.json manifest.
|
|
115
|
+
* @param {string} downloadUrl The download_url value from the manifest.
|
|
116
|
+
* @returns {string} The final, absolute URL for the .gin package.
|
|
117
|
+
* @private
|
|
118
|
+
*/
|
|
119
|
+
function _resolveDownloadUrl(storeUrl, downloadUrl) {
|
|
120
|
+
// Check if downloadUrl is already an absolute URL.
|
|
121
|
+
if (downloadUrl.startsWith('http://') || downloadUrl.startsWith('https://')) {
|
|
122
|
+
return downloadUrl;
|
|
123
|
+
}
|
|
124
|
+
// If it's relative, resolve it against the store's base URL.
|
|
125
|
+
const storeBaseUrl = new URL('.', storeUrl).toString();
|
|
126
|
+
return new URL(downloadUrl, storeBaseUrl).toString();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _getHttpClientErrorMessage(err) {
|
|
130
|
+
let message = '';
|
|
131
|
+
if (err.response) {
|
|
132
|
+
if(err.response.status === 401)
|
|
133
|
+
message = 'Unauthorized: Please login using the login command';
|
|
134
|
+
else
|
|
135
|
+
message = `${err.response.status}: ${err.response.data.message || 'Server error.'}`;
|
|
136
|
+
} else if (err.request) {
|
|
137
|
+
message = 'Network Error: No response received. Check if server is running.';
|
|
138
|
+
} else {
|
|
139
|
+
message = `Error: ${err.message}`;
|
|
140
|
+
}
|
|
141
|
+
return message;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Securely unzips a buffer to an absolute destination path.
|
|
146
|
+
* This is a self-contained utility for the CLI.
|
|
147
|
+
* @private
|
|
148
|
+
*/
|
|
149
|
+
async function _unzipBuffer(zipBuffer, destAbsolutePath) {
|
|
150
|
+
fs.mkdirSync(destAbsolutePath, { recursive: true });
|
|
151
|
+
const zipfile = await new Promise((resolve, reject) => {
|
|
152
|
+
yauzl.fromBuffer(zipBuffer, { lazyEntries: true }, (err, zf) => err ? reject(err) : resolve(zf));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await new Promise((resolve, reject) => {
|
|
156
|
+
zipfile.on('error', reject);
|
|
157
|
+
zipfile.on('end', resolve);
|
|
158
|
+
zipfile.on('entry', (entry) => {
|
|
159
|
+
const finalDestPath = path.join(destAbsolutePath, entry.fileName);
|
|
160
|
+
const resolvedPath = path.resolve(finalDestPath);
|
|
161
|
+
if (!resolvedPath.startsWith(destAbsolutePath)) {
|
|
162
|
+
return reject(new Error(`Security Error: Zip file contains path traversal ('${entry.fileName}').`));
|
|
163
|
+
}
|
|
164
|
+
if (/\/$/.test(entry.fileName)) {
|
|
165
|
+
fs.mkdirSync(resolvedPath, { recursive: true });
|
|
166
|
+
zipfile.readEntry();
|
|
167
|
+
} else {
|
|
168
|
+
zipfile.openReadStream(entry, (err, readStream) => {
|
|
169
|
+
if (err) return reject(err);
|
|
170
|
+
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
171
|
+
const writeStream = fs.createWriteStream(resolvedPath);
|
|
172
|
+
readStream.pipe(writeStream).on('finish', () => zipfile.readEntry()).on('error', reject);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
zipfile.readEntry();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function _repackApp(sourcePath) {
|
|
181
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
182
|
+
const buffers = [];
|
|
183
|
+
archive.on('data', buffer => buffers.push(buffer));
|
|
184
|
+
const streamPromise = new Promise((resolve, reject) => {
|
|
185
|
+
archive.on('end', () => resolve(Buffer.concat(buffers)));
|
|
186
|
+
archive.on('error', reject);
|
|
187
|
+
});
|
|
188
|
+
archive.directory(sourcePath, false);
|
|
189
|
+
await archive.finalize();
|
|
190
|
+
return streamPromise;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function _getPermissions(unpackedPath) {
|
|
194
|
+
const { default: chalk } = await import('chalk');
|
|
195
|
+
const { default: inquirer } = await import('inquirer');
|
|
196
|
+
|
|
197
|
+
const pmftPath = path.join(unpackedPath, 'box', 'pmft.json');
|
|
198
|
+
if (!fs.existsSync(pmftPath)) {
|
|
199
|
+
throw new Error('Package is invalid: Missing permissions manifest (pmft.json).');
|
|
200
|
+
}
|
|
201
|
+
const pmft = await fs.readJson(pmftPath);
|
|
202
|
+
const appJson = await fs.readJson(path.join(unpackedPath, 'box', 'app.json'));
|
|
203
|
+
|
|
204
|
+
console.log(`\n--- Application Details ---`);
|
|
205
|
+
console.log(chalk.blueBright(` App: ${appJson.name} (v${appJson.version})`));
|
|
206
|
+
console.log(chalk.blueBright(` Description: ${appJson.description}\n`));
|
|
207
|
+
console.log(chalk.yellow('This application requests the following permissions:'));
|
|
208
|
+
|
|
209
|
+
const mandatoryPerms = pmft.permissions.mandatory || [];
|
|
210
|
+
const optionalPerms = pmft.permissions.optional || [];
|
|
211
|
+
|
|
212
|
+
console.log(chalk.bgRedBright('\nMandatory Permissions:'));
|
|
213
|
+
mandatoryPerms.forEach(p => console.log(`- ${chalk.bold(p)}: ${PERMISSION_DESCRIPTIONS[p]}`));
|
|
214
|
+
|
|
215
|
+
const { consent } = await inquirer.prompt([{ type: 'confirm', name: 'consent', message: '\nDo you grant the mandatory permissions?', default: false }]);
|
|
216
|
+
if (!consent) throw new Error('Installation cancelled by user.');
|
|
217
|
+
|
|
218
|
+
console.log(chalk.bgYellow('\nOptional Permissions:'));
|
|
219
|
+
optionalPerms.forEach(p => console.log(`- ${p}: ${PERMISSION_DESCRIPTIONS[p]}`));
|
|
220
|
+
|
|
221
|
+
let grantedPermissions = [...mandatoryPerms];
|
|
222
|
+
if (optionalPerms.length > 0) {
|
|
223
|
+
const { chosenOptionals } = await inquirer.prompt([{ type: 'checkbox', name: 'chosenOptionals', message: '\nSelect any optional permissions to grant:', choices: optionalPerms }]);
|
|
224
|
+
grantedPermissions.push(...chosenOptionals);
|
|
225
|
+
}
|
|
226
|
+
return grantedPermissions;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function _getDbRequirements(unpackedPath) {
|
|
230
|
+
const { default: chalk } = await import('chalk');
|
|
231
|
+
const { default: inquirer } = await import('inquirer');
|
|
232
|
+
|
|
233
|
+
// Step 1: Read the app.json from the unpacked package.
|
|
234
|
+
const appJson = await fs.readJson(path.join(unpackedPath, 'box', 'app.json'));
|
|
235
|
+
const dbConnections = appJson.db || [];
|
|
236
|
+
if (dbConnections.length === 0) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log('\n--- Configuring Database Requirements ---');
|
|
241
|
+
const newDbConfigs = [];
|
|
242
|
+
|
|
243
|
+
// Step 2: Loop through each database connection defined in the app.json
|
|
244
|
+
for (const dbReq of dbConnections) {
|
|
245
|
+
console.log(chalk.cyan(`\nThis app's app.json requests a '${dbReq.type}' database connection named '${dbReq.name}'.`));
|
|
246
|
+
console.log(chalk.cyan(`Please provide or confirm the following details:`));
|
|
247
|
+
|
|
248
|
+
const questions = [];
|
|
249
|
+
const keysToPrompt = Object.keys(dbReq);
|
|
250
|
+
|
|
251
|
+
// Step 3: Dynamically generate prompts based on the keys in the app.json db object.
|
|
252
|
+
for (const key of keysToPrompt) {
|
|
253
|
+
// We don't prompt for these structural keys.
|
|
254
|
+
if (key === 'type' || key === 'name') {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const question = {
|
|
259
|
+
name: key,
|
|
260
|
+
message: `${key}:`,
|
|
261
|
+
// Use the value from app.json as the default.
|
|
262
|
+
default: dbReq[key]
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Special handling for the password key.
|
|
266
|
+
if (key === 'password') {
|
|
267
|
+
question.type = 'password';
|
|
268
|
+
question.mask = '*';
|
|
269
|
+
// CRITICAL: Never use a placeholder/default for a password prompt.
|
|
270
|
+
delete question.default;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Special handling for sqlite to provide a more descriptive message.
|
|
274
|
+
if (key === 'database' && dbReq.type === 'sqlite') {
|
|
275
|
+
question.message = 'Database File Path (relative to the app\'s `box` folder):';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
questions.push(question);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (questions.length > 0) {
|
|
282
|
+
const answers = await inquirer.prompt(questions);
|
|
283
|
+
// Merge the user's answers over the original config from app.json
|
|
284
|
+
newDbConfigs.push({ ...dbReq, ...answers });
|
|
285
|
+
} else {
|
|
286
|
+
// If there were no keys to prompt for, pass the original config through.
|
|
287
|
+
newDbConfigs.push(dbReq);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return newDbConfigs;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function _getUpgradePermissions(unpackedPath, currentPermissions) {
|
|
294
|
+
const { default: chalk } = await import('chalk');
|
|
295
|
+
const { default: inquirer } = await import('inquirer');
|
|
296
|
+
|
|
297
|
+
const pmftPath = path.join(unpackedPath, 'box', 'pmft.json');
|
|
298
|
+
if (!fs.existsSync(pmftPath)) {
|
|
299
|
+
throw new Error('New package is invalid: Missing permissions manifest (pmft.json).');
|
|
300
|
+
}
|
|
301
|
+
const pmft = await fs.readJson(pmftPath);
|
|
302
|
+
const appJson = await fs.readJson(path.join(unpackedPath, 'box', 'app.json'));
|
|
303
|
+
|
|
304
|
+
console.log(`\n--- Upgrading Application ---`);
|
|
305
|
+
console.log(chalk.blueBright(` App: ${appJson.name} (v${appJson.version})`));
|
|
306
|
+
console.log(chalk.yellow('\nReview the following permission changes:'));
|
|
307
|
+
|
|
308
|
+
const newMandatorySet = new Set(pmft.permissions.mandatory || []);
|
|
309
|
+
const newOptionalSet = new Set(pmft.permissions.optional || []);
|
|
310
|
+
const currentGrantedSet = new Set(currentPermissions);
|
|
311
|
+
const allNewRequestedSet = new Set([...newMandatorySet, ...newOptionalSet]);
|
|
312
|
+
|
|
313
|
+
const newlyRequestedMandatory = [...newMandatorySet].filter(p => !currentGrantedSet.has(p));
|
|
314
|
+
const newlyRequestedOptional = [...newOptionalSet].filter(p => !currentGrantedSet.has(p));
|
|
315
|
+
const permissionsToRevoke = [...currentGrantedSet].filter(p => !allNewRequestedSet.has(p));
|
|
316
|
+
const unchangedPermissions = [...currentGrantedSet].filter(p => allNewRequestedSet.has(p));
|
|
317
|
+
|
|
318
|
+
const noChanges = newlyRequestedMandatory.length === 0 && newlyRequestedOptional.length === 0 && permissionsToRevoke.length === 0;
|
|
319
|
+
|
|
320
|
+
if (noChanges) {
|
|
321
|
+
console.log(chalk.blueBright('- No permission changes are required for this upgrade.'));
|
|
322
|
+
return currentPermissions; // Return the existing permissions as is.
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
newlyRequestedMandatory.forEach(p => console.log(chalk.blueBright(`+ GRANT (Mandatory): ${p} - ${PERMISSION_DESCRIPTIONS[p]}`)));
|
|
326
|
+
newlyRequestedOptional.forEach(p => console.log(chalk.blueBright(`+ GRANT (Optional): ${p} - ${PERMISSION_DESCRIPTIONS[p]}`)));
|
|
327
|
+
permissionsToRevoke.forEach(p => console.log(chalk.blueBright(`- REVOKE (No longer requested): ${p}`)));
|
|
328
|
+
|
|
329
|
+
if (newlyRequestedMandatory.length > 0) {
|
|
330
|
+
const { mandatoryConsent } = await inquirer.prompt([{
|
|
331
|
+
type: 'confirm',
|
|
332
|
+
name: 'mandatoryConsent',
|
|
333
|
+
message: `This upgrade requires new MANDATORY permissions. Do you approve granting them?`,
|
|
334
|
+
default: false
|
|
335
|
+
}]);
|
|
336
|
+
if (!mandatoryConsent) throw new Error('Mandatory permissions denied. Upgrade cancelled by user.');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let chosenOptionals = [];
|
|
340
|
+
if (newlyRequestedOptional.length > 0) {
|
|
341
|
+
const { chosen } = await inquirer.prompt([{
|
|
342
|
+
type: 'checkbox',
|
|
343
|
+
name: 'chosen',
|
|
344
|
+
message: 'Please select which new OPTIONAL permissions you wish to grant:',
|
|
345
|
+
choices: newlyRequestedOptional
|
|
346
|
+
}]);
|
|
347
|
+
chosenOptionals = chosen;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const finalPermissions = new Set([
|
|
351
|
+
...unchangedPermissions,
|
|
352
|
+
...newlyRequestedMandatory,
|
|
353
|
+
...chosenOptionals
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
console.log(chalk.blueBright('\nPermissions confirmed.'));
|
|
357
|
+
return Array.from(finalPermissions);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function _getRollbackPermissions(backupPermissions, currentPermissions) {
|
|
361
|
+
const { default: chalk } = await import('chalk');
|
|
362
|
+
const { default: inquirer } = await import('inquirer');
|
|
363
|
+
|
|
364
|
+
console.log(chalk.yellow('\nPlease review the following permission changes for the rollback:'));
|
|
365
|
+
|
|
366
|
+
const backupMandatorySet = new Set(backupPermissions.mandatory || []);
|
|
367
|
+
const backupOptionalSet = new Set(backupPermissions.optional || []);
|
|
368
|
+
const currentGrantedSet = new Set(currentPermissions);
|
|
369
|
+
const allBackupRequestedSet = new Set([...backupMandatorySet, ...backupOptionalSet]);
|
|
370
|
+
|
|
371
|
+
const toRevoke = [...currentGrantedSet].filter(p => !allBackupRequestedSet.has(p));
|
|
372
|
+
const toGrantMandatory = [...backupMandatorySet].filter(p => !currentGrantedSet.has(p));
|
|
373
|
+
const toGrantOptional = [...backupOptionalSet].filter(p => !currentGrantedSet.has(p));
|
|
374
|
+
|
|
375
|
+
const noChanges = toRevoke.length === 0 && toGrantMandatory.length === 0 && toGrantOptional.length === 0;
|
|
376
|
+
|
|
377
|
+
if (noChanges) {
|
|
378
|
+
console.log(chalk.bgBlueBright('- No permission changes are required for this rollback.'));
|
|
379
|
+
return currentPermissions;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
toGrantMandatory.forEach(p => console.log(chalk.blueBright(`+ GRANT (Mandatory): ${p} - ${PERMISSION_DESCRIPTIONS[p]}`)));
|
|
383
|
+
toGrantOptional.forEach(p => console.log(chalk.blueBright(`+ GRANT (Optional): ${p} - ${PERMISSION_DESCRIPTIONS[p]}`)));
|
|
384
|
+
toRevoke.forEach(p => console.log(chalk.blueBright(`- REVOKE (No longer requested): ${p}`)));
|
|
385
|
+
|
|
386
|
+
if (toGrantMandatory.length > 0) {
|
|
387
|
+
const { mandatoryConsent } = await inquirer.prompt([{
|
|
388
|
+
type: 'confirm',
|
|
389
|
+
name: 'mandatoryConsent',
|
|
390
|
+
message: `This rollback requires granting new MANDATORY permissions. Do you approve?`,
|
|
391
|
+
default: false
|
|
392
|
+
}]);
|
|
393
|
+
if (!mandatoryConsent) throw new Error('Mandatory permissions denied. Rollback cancelled by user.');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let chosenOptionals = [];
|
|
397
|
+
if (toGrantOptional.length > 0) {
|
|
398
|
+
const { chosen } = await inquirer.prompt([{
|
|
399
|
+
type: 'checkbox',
|
|
400
|
+
name: 'chosen',
|
|
401
|
+
message: 'Please select which new OPTIONAL permissions you wish to grant for the rolled-back version:',
|
|
402
|
+
choices: toGrantOptional
|
|
403
|
+
}]);
|
|
404
|
+
chosenOptionals = chosen;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const finalPermissions = new Set([
|
|
408
|
+
...[...currentGrantedSet].filter(p => allBackupRequestedSet.has(p)),
|
|
409
|
+
...toGrantMandatory,
|
|
410
|
+
...chosenOptionals
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
console.log(chalk.blueBright('\nPermissions confirmed.'));
|
|
414
|
+
return Array.from(finalPermissions);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = {
|
|
418
|
+
_readAppPreset,
|
|
419
|
+
_validateAppPreset,
|
|
420
|
+
_substituteEnvVars,
|
|
421
|
+
_unzipBuffer,
|
|
422
|
+
_repackApp,
|
|
423
|
+
_getPermissions,
|
|
424
|
+
_getUpgradePermissions,
|
|
425
|
+
_getRollbackPermissions,
|
|
426
|
+
_getDbRequirements,
|
|
427
|
+
_resolveStoreUrl,
|
|
428
|
+
_resolveDownloadUrl,
|
|
429
|
+
_getHttpClientErrorMessage
|
|
430
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { getAuthenticatedClient } = require('./apiClient');
|
|
2
|
+
|
|
3
|
+
async function listApps(options) {
|
|
4
|
+
const { default: chalk } = await import('chalk');
|
|
5
|
+
const { default: ora } = await import('ora');
|
|
6
|
+
const spinner = ora('Fetching application list...').start();
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const { serverUrl = 'http://localhost:7070' } = options;
|
|
10
|
+
const { client } = await getAuthenticatedClient(serverUrl);
|
|
11
|
+
const response = await client.get(`${serverUrl}/glade/api/apps`);
|
|
12
|
+
|
|
13
|
+
if (response.data.status !== 'success' || !response.data.apps) {
|
|
14
|
+
throw new Error("Failed to retrieve app list from server.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
spinner.stop(); // Stop the spinner before printing the table
|
|
18
|
+
|
|
19
|
+
const apps = response.data.apps;
|
|
20
|
+
if (apps.length === 0) {
|
|
21
|
+
console.log(chalk.yellow('No applications are currently installed.'));
|
|
22
|
+
} else {
|
|
23
|
+
console.log(chalk.blueBright(`Installed Applications at Gingee Server : ${serverUrl}`));
|
|
24
|
+
console.table(apps);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
} catch (err) {
|
|
28
|
+
spinner.fail(chalk.bgRed('Error!'));
|
|
29
|
+
console.error(chalk.blueBright(`Failed to fetch apps.`));
|
|
30
|
+
if (err.errors) { //for AggregateError
|
|
31
|
+
const messages = err.errors.map(e => e.message).join('\n');
|
|
32
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`${messages}`));
|
|
33
|
+
} else {
|
|
34
|
+
const message = err.response ? (err.response.data.error || 'Server error.') : err.message;
|
|
35
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`${message}`));
|
|
36
|
+
}
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { listApps };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const apiClient = require('./apiClient');
|
|
2
|
+
|
|
3
|
+
async function listBackups(options) {
|
|
4
|
+
const { default: chalk } = await import('chalk');
|
|
5
|
+
const { default: ora } = await import('ora');
|
|
6
|
+
const { serverUrl, appName } = options;
|
|
7
|
+
const spinner = ora(`Fetching backups for '${appName}'...`).start();
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const result = await apiClient.listBackups(serverUrl, appName);
|
|
11
|
+
|
|
12
|
+
if (result.status !== 'success' || !result.backups) {
|
|
13
|
+
throw new Error(result.message || "Failed to retrieve backup list from server.");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
spinner.stop();
|
|
17
|
+
|
|
18
|
+
const backups = result.backups;
|
|
19
|
+
if (backups.length === 0) {
|
|
20
|
+
console.log(chalk.blueBright(`No backups found for application '${appName}'.`));
|
|
21
|
+
} else {
|
|
22
|
+
console.log(chalk.blueBright(`Available Backups for '${appName}' at ${serverUrl}:`));
|
|
23
|
+
// Format for console.table
|
|
24
|
+
const formattedBackups = backups.map(b => ({ 'Backup Filename': b }));
|
|
25
|
+
console.table(formattedBackups);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
} catch (err) {
|
|
29
|
+
spinner.fail(chalk.bgRed('Failed to fetch backups.'));
|
|
30
|
+
if (err.errors) { //for AggregateError
|
|
31
|
+
const messages = err.errors.map(e => e.message).join('\n');
|
|
32
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`${messages}`));
|
|
33
|
+
} else {
|
|
34
|
+
const message = _getHttpClientErrorMessage(err);
|
|
35
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`${message}`));
|
|
36
|
+
}
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { listBackups };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const {_resolveStoreUrl, _getHttpClientErrorMessage} = require('./installerUtils');
|
|
3
|
+
|
|
4
|
+
async function listStoreApps(options) {
|
|
5
|
+
const { default: chalk } = await import('chalk');
|
|
6
|
+
const { default: ora } = await import('ora');
|
|
7
|
+
|
|
8
|
+
const { gStoreUrl } = options;
|
|
9
|
+
|
|
10
|
+
let resolvedUrl;
|
|
11
|
+
try {
|
|
12
|
+
// Resolve the URL before doing anything else.
|
|
13
|
+
resolvedUrl = _resolveStoreUrl(gStoreUrl);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
// This will catch errors from our resolver (e.g., invalid filename) or the URL constructor.
|
|
16
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(err.message));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const spinner = ora(`Fetching app store manifest from ${resolvedUrl}...`).start();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await axios.get(resolvedUrl);
|
|
24
|
+
const manifest = response.data;
|
|
25
|
+
|
|
26
|
+
if (!manifest || !Array.isArray(manifest.apps)) {
|
|
27
|
+
throw new Error('Invalid or missing "apps" array in the gstore.json manifest.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
spinner.succeed(chalk.bgGreen(`Successfully fetched manifest for: ${manifest.storeName}`));
|
|
31
|
+
|
|
32
|
+
const appData = manifest.apps.map(app => ({
|
|
33
|
+
Name: app.name,
|
|
34
|
+
Version: app.version,
|
|
35
|
+
Publisher: app.publisher ? app.publisher.name : 'N/A',
|
|
36
|
+
Description: app.description
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
if (appData.length === 0) {
|
|
40
|
+
console.log(chalk.yellow('No applications found in this store.'));
|
|
41
|
+
} else {
|
|
42
|
+
console.table(appData);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
} catch (err) {
|
|
46
|
+
spinner.fail(chalk.bgRed('Failed to fetch store manifest.'));
|
|
47
|
+
if (err.errors) { //for AggregateError
|
|
48
|
+
const messages = err.errors.map(e => e.message).join('\n');
|
|
49
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`${messages}`));
|
|
50
|
+
} else if(err.response && err.response.status === 404) {
|
|
51
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`Store not found at ${resolvedUrl}. Please check the URL and try again.`));
|
|
52
|
+
} else {
|
|
53
|
+
const message = _getHttpClientErrorMessage(err);
|
|
54
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`${message}`));
|
|
55
|
+
}
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { listStoreApps };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const { getCredsFilePath } = require('./apiClient');
|
|
6
|
+
|
|
7
|
+
const configDir = path.join(os.homedir(), '.gingee');
|
|
8
|
+
|
|
9
|
+
async function login(options = {}) {
|
|
10
|
+
let { serverUrl = 'http://localhost:7070', username = 'admin', password } = options;
|
|
11
|
+
const credsPath = getCredsFilePath(serverUrl);
|
|
12
|
+
|
|
13
|
+
const { default: chalk } = await import('chalk');
|
|
14
|
+
const { default: inquirer } = await import('inquirer');
|
|
15
|
+
const { default: ora } = await import('ora');
|
|
16
|
+
const spinner = ora();
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
console.log(chalk.blueBright(`Logging into Glade admin panel at: ${serverUrl}`));
|
|
20
|
+
if(!username || !password) {
|
|
21
|
+
({ username, password } = await inquirer.prompt([
|
|
22
|
+
{ type: 'input', name: 'username', message: 'Username:', default: 'admin' },
|
|
23
|
+
{ type: 'password', name: 'password', message: 'Password:', mask: '*' }
|
|
24
|
+
]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
spinner.start('Authenticating...');
|
|
28
|
+
|
|
29
|
+
const response = await axios.post(`${serverUrl}/glade/login`, { username, password });
|
|
30
|
+
|
|
31
|
+
if (response.data.status !== 'success') {
|
|
32
|
+
throw new Error('Authentication failed. Please check your credentials.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read the 'set-cookie' header directly from the response.
|
|
36
|
+
const setCookieHeader = response.headers['set-cookie'];
|
|
37
|
+
if (!setCookieHeader || setCookieHeader.length === 0) {
|
|
38
|
+
throw new Error('Login succeeded, but the server did not send a session cookie.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The header can be an array of cookies. We are interested in our 'sessionId'.
|
|
42
|
+
// We take the first part of the cookie string, before the attributes (HttpOnly, etc.)
|
|
43
|
+
const sessionCookie = setCookieHeader
|
|
44
|
+
.find(c => c.startsWith('sessionId='))
|
|
45
|
+
.split(';')[0];
|
|
46
|
+
|
|
47
|
+
if (!sessionCookie) {
|
|
48
|
+
throw new Error('Could not find a valid sessionId cookie in the server response.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Save this definitive cookie string to the credentials file.
|
|
52
|
+
fs.ensureDirSync(configDir);
|
|
53
|
+
fs.writeJsonSync(credsPath, { serverUrl, cookie: sessionCookie });
|
|
54
|
+
|
|
55
|
+
const successMsg = chalk.bgGreen('Success') + chalk.blueBright(` Logged in. Session saved.`);
|
|
56
|
+
spinner.succeed(successMsg);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
spinner.fail(chalk.bgRed('Error: '), chalk.blueBright('Login failed!'));
|
|
59
|
+
if (err.errors) { //for AggregateError
|
|
60
|
+
const messages = err.errors.map(e => e.message).join('\n');
|
|
61
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`${messages}`));
|
|
62
|
+
} else {
|
|
63
|
+
const message = err.response ? (err.response.data.message || 'Invalid credentials.') : err.message;
|
|
64
|
+
console.error(chalk.bgRed(`Error: `), chalk.blueBright(`${message}`));
|
|
65
|
+
}
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { login };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const { deleteSession } = require('./apiClient');
|
|
2
|
+
|
|
3
|
+
async function logout(options) {
|
|
4
|
+
const { default: chalk } = await import('chalk');
|
|
5
|
+
const { serverUrl = 'http://localhost:7070' } = options;
|
|
6
|
+
deleteSession(serverUrl);
|
|
7
|
+
console.log(chalk.bgGreen('✅ Logged out: '), chalk.blueBright(`You have been logged out from Gingee Glade at - ${serverUrl}`));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = { logout };
|