atlasia-ghost 0.0.1
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 +2 -0
- package/ghost.js +361 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adel Lamallam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/ghost.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 👻 Ghost CLI - Assistant Git Intelligent (Node.js Edition)
|
|
5
|
+
* Zero-Dependency: Utilise uniquement les modules natifs Node.js
|
|
6
|
+
* Compatible: Windows, Mac, Linux
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const { execSync } = require('child_process');
|
|
13
|
+
const readline = require('readline');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
// ==============================================================================
|
|
17
|
+
// ⚙️ CONFIGURATION & CONSTANTES
|
|
18
|
+
// ==============================================================================
|
|
19
|
+
const CONFIG_FILE = path.join(os.homedir(), '.ghost');
|
|
20
|
+
const SAFE_EXTENSIONS = new Set(['.md', '.txt', '.csv', '.html', '.css', '.scss', '.lock', '.xml', '.json']);
|
|
21
|
+
const SAFE_FILES = new Set(['mvnw', 'gradlew', 'package-lock.json', 'yarn.lock', 'pom.xml']);
|
|
22
|
+
|
|
23
|
+
// Modèles disponibles sur Groq (Basé sur votre plan gratuit)
|
|
24
|
+
// llama-3.3-70b-versatile : Intelligent, Idéal pour la sécurité (Limit: 1k RPD, 12k TPM)
|
|
25
|
+
// llama-3.1-8b-instant : Rapide, Idéal si quota dépassé (Limit: 14.4k RPD, 6k TPM)
|
|
26
|
+
const DEFAULT_MODEL = "llama-3.3-70b-versatile";
|
|
27
|
+
|
|
28
|
+
// Couleurs ANSI pour un terminal plus beau
|
|
29
|
+
const Colors = {
|
|
30
|
+
HEADER: '\x1b[95m',
|
|
31
|
+
BLUE: '\x1b[94m',
|
|
32
|
+
CYAN: '\x1b[96m',
|
|
33
|
+
GREEN: '\x1b[92m',
|
|
34
|
+
WARNING: '\x1b[93m',
|
|
35
|
+
FAIL: '\x1b[91m',
|
|
36
|
+
ENDC: '\x1b[0m',
|
|
37
|
+
BOLD: '\x1b[1m',
|
|
38
|
+
DIM: '\x1b[2m'
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ==============================================================================
|
|
42
|
+
// 🔧 GESTIONNAIRE DE CONFIGURATION
|
|
43
|
+
// ==============================================================================
|
|
44
|
+
class ConfigManager {
|
|
45
|
+
constructor() {
|
|
46
|
+
this.config = {};
|
|
47
|
+
this.load();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
load() {
|
|
51
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
52
|
+
try {
|
|
53
|
+
this.config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
54
|
+
} catch (e) {
|
|
55
|
+
this.config = {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
save() {
|
|
61
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 4));
|
|
62
|
+
console.log(`${Colors.DIM}Configuration sauvegardée dans ${CONFIG_FILE}${Colors.ENDC}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getApiKey() {
|
|
66
|
+
let key = process.env.GROQ_API_KEY || this.config.groq_api_key;
|
|
67
|
+
|
|
68
|
+
if (!key) {
|
|
69
|
+
console.log(`\n${Colors.WARNING}⚠️ Configuration manquante${Colors.ENDC}`);
|
|
70
|
+
console.log(`${Colors.DIM}Pour utiliser Ghost, vous avez besoin d'une clé API Groq (Gratuite).${Colors.ENDC}`);
|
|
71
|
+
console.log(`${Colors.BLUE}👉 Obtenir une clé : https://console.groq.com${Colors.ENDC}\n`);
|
|
72
|
+
|
|
73
|
+
key = await promptUser(`${Colors.BOLD}Collez votre clé Groq (gsk_...) : ${Colors.ENDC}`);
|
|
74
|
+
|
|
75
|
+
if (key && key.trim().startsWith('gsk_')) {
|
|
76
|
+
this.config.groq_api_key = key.trim();
|
|
77
|
+
this.save();
|
|
78
|
+
} else {
|
|
79
|
+
console.log(`${Colors.FAIL}❌ Clé invalide ou manquante. Abandon.${Colors.ENDC}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return key;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getModel() {
|
|
87
|
+
if (!this.config.model) {
|
|
88
|
+
this.config.model = DEFAULT_MODEL;
|
|
89
|
+
this.save();
|
|
90
|
+
}
|
|
91
|
+
return this.config.model;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ==============================================================================
|
|
96
|
+
// 🧠 MOTEUR IA (Client HTTPS Natif)
|
|
97
|
+
// ==============================================================================
|
|
98
|
+
class AIEngine {
|
|
99
|
+
constructor(apiKey, model) {
|
|
100
|
+
this.apiKey = apiKey;
|
|
101
|
+
this.hostname = "api.groq.com";
|
|
102
|
+
this.path = "/openai/v1/chat/completions";
|
|
103
|
+
this.model = model || DEFAULT_MODEL;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async call(systemPrompt, userPrompt, temperature = 0.3, jsonMode = false) {
|
|
107
|
+
const payload = {
|
|
108
|
+
model: this.model,
|
|
109
|
+
messages: [
|
|
110
|
+
{ role: "system", content: systemPrompt },
|
|
111
|
+
{ role: "user", content: userPrompt }
|
|
112
|
+
],
|
|
113
|
+
temperature: temperature
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (jsonMode) {
|
|
117
|
+
payload.response_format = { type: "json_object" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const options = {
|
|
121
|
+
hostname: this.hostname,
|
|
122
|
+
path: this.path,
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: {
|
|
125
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
'User-Agent': 'Mozilla/5.0 (Node.js/GhostCLI)'
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const req = https.request(options, (res) => {
|
|
133
|
+
let data = '';
|
|
134
|
+
res.on('data', (chunk) => data += chunk);
|
|
135
|
+
res.on('end', () => {
|
|
136
|
+
if (res.statusCode >= 400) {
|
|
137
|
+
try {
|
|
138
|
+
const errBody = JSON.parse(data);
|
|
139
|
+
reject(new Error(`API Error ${res.statusCode}: ${errBody.error?.message || data}`));
|
|
140
|
+
} catch (e) {
|
|
141
|
+
reject(new Error(`API Error ${res.statusCode}: ${data}`));
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
try {
|
|
145
|
+
const result = JSON.parse(data);
|
|
146
|
+
resolve(result.choices[0].message.content);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
reject(e);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
req.on('error', (e) => reject(e));
|
|
155
|
+
req.write(JSON.stringify(payload));
|
|
156
|
+
req.end();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ==============================================================================
|
|
162
|
+
// 🛡️ SCANNER DE SECURITE
|
|
163
|
+
// ==============================================================================
|
|
164
|
+
function calculateShannonEntropy(data) {
|
|
165
|
+
if (!data) return 0;
|
|
166
|
+
const frequencies = {};
|
|
167
|
+
for (let char of data) {
|
|
168
|
+
frequencies[char] = (frequencies[char] || 0) + 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let entropy = 0;
|
|
172
|
+
const len = data.length;
|
|
173
|
+
for (let char in frequencies) {
|
|
174
|
+
const p = frequencies[char] / len;
|
|
175
|
+
entropy -= p * Math.log2(p);
|
|
176
|
+
}
|
|
177
|
+
return entropy;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function scanForSecrets(content) {
|
|
181
|
+
if (!content) return [];
|
|
182
|
+
const suspicious = [];
|
|
183
|
+
// Regex : Cherche ce qui est entre quotes ou après un signe égal
|
|
184
|
+
const regex = /(['"])(.*?)(\1)|=\s*([^\s]+)/g;
|
|
185
|
+
let match;
|
|
186
|
+
|
|
187
|
+
while ((match = regex.exec(content)) !== null) {
|
|
188
|
+
const candidate = match[2] || match[4];
|
|
189
|
+
|
|
190
|
+
// Filtres heuristiques de base
|
|
191
|
+
if (!candidate || candidate.length < 12 || candidate.includes(' ')) continue;
|
|
192
|
+
|
|
193
|
+
// Analyse mathématique (Entropie > 4.8 est souvent un secret)
|
|
194
|
+
if (calculateShannonEntropy(candidate) > 4.8) {
|
|
195
|
+
suspicious.push(candidate.substring(0, 15) + "...");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return suspicious;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ==============================================================================
|
|
202
|
+
// 📂 GIT INTERFACE
|
|
203
|
+
// ==============================================================================
|
|
204
|
+
function gitExec(args, suppressError = false) {
|
|
205
|
+
try {
|
|
206
|
+
return execSync(`git ${args.join(' ')}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (!suppressError && e.stderr) {
|
|
209
|
+
// On ignore les erreurs mineures
|
|
210
|
+
}
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function checkGitRepo() {
|
|
216
|
+
try {
|
|
217
|
+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
|
|
218
|
+
return true;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getStagedDiff() {
|
|
225
|
+
const filesOutput = gitExec(['diff', '--cached', '--name-only']);
|
|
226
|
+
if (!filesOutput) return { text: "", map: {}, files: [] };
|
|
227
|
+
|
|
228
|
+
const files = filesOutput.split('\n');
|
|
229
|
+
let fullDiff = "";
|
|
230
|
+
const fileMap = {};
|
|
231
|
+
const validFiles = [];
|
|
232
|
+
|
|
233
|
+
for (let f of files) {
|
|
234
|
+
f = f.trim().replace(/^"|"$/g, '');
|
|
235
|
+
if (!f || SAFE_FILES.has(path.basename(f)) || SAFE_EXTENSIONS.has(path.extname(f))) continue;
|
|
236
|
+
|
|
237
|
+
const content = gitExec(['diff', '--cached', `"${f}"`]);
|
|
238
|
+
if (content) {
|
|
239
|
+
fullDiff += `\n--- ${f} ---\n${content}\n`;
|
|
240
|
+
fileMap[f] = content;
|
|
241
|
+
validFiles.push(f);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { text: fullDiff, map: fileMap, files: validFiles };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function promptUser(question) {
|
|
249
|
+
const rl = readline.createInterface({
|
|
250
|
+
input: process.stdin,
|
|
251
|
+
output: process.stdout
|
|
252
|
+
});
|
|
253
|
+
return new Promise(resolve => {
|
|
254
|
+
rl.question(question, (answer) => {
|
|
255
|
+
rl.close();
|
|
256
|
+
resolve(answer);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ==============================================================================
|
|
262
|
+
// 🚀 MAIN LOOP
|
|
263
|
+
// ==============================================================================
|
|
264
|
+
async function main() {
|
|
265
|
+
console.clear();
|
|
266
|
+
console.log(`\n${Colors.BOLD}${Colors.CYAN} 👻 GHOST CLI ${Colors.ENDC}${Colors.DIM} v1.0.0${Colors.ENDC}`);
|
|
267
|
+
console.log(`${Colors.DIM} ─────────────────────────────────────${Colors.ENDC}\n`);
|
|
268
|
+
|
|
269
|
+
// 1. Vérification Git
|
|
270
|
+
if (!checkGitRepo()) {
|
|
271
|
+
console.log(`${Colors.FAIL}❌ Erreur : Ce dossier n'est pas un dépôt Git.${Colors.ENDC}`);
|
|
272
|
+
console.log(`💡 Solution : Lancez ${Colors.BOLD}git init${Colors.ENDC} d'abord.`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const config = new ConfigManager();
|
|
277
|
+
const apiKey = await config.getApiKey();
|
|
278
|
+
const model = config.getModel(); // Récupère le modèle configuré
|
|
279
|
+
const ai = new AIEngine(apiKey, model);
|
|
280
|
+
|
|
281
|
+
// 2. Récupération du Diff
|
|
282
|
+
const { text: fullDiffText, map: diffMap, files: fileList } = getStagedDiff();
|
|
283
|
+
|
|
284
|
+
if (!fullDiffText) {
|
|
285
|
+
console.log(`${Colors.WARNING}⚠️ Rien à commiter.${Colors.ENDC}`);
|
|
286
|
+
console.log(`💡 Astuce : Utilisez ${Colors.BOLD}git add <fichier>${Colors.ENDC} pour préparer vos changements.\n`);
|
|
287
|
+
process.exit(0);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Affichage des fichiers détectés
|
|
291
|
+
console.log(`${Colors.BOLD}📂 Fichiers détectés (${fileList.length}) :${Colors.ENDC}`);
|
|
292
|
+
fileList.forEach(f => console.log(` ${Colors.DIM}• ${f}${Colors.ENDC}`));
|
|
293
|
+
console.log(""); // Saut de ligne
|
|
294
|
+
|
|
295
|
+
// 3. Audit de Sécurité
|
|
296
|
+
process.stdout.write(`${Colors.BLUE}🛡️ [1/2] Audit de Sécurité... ${Colors.ENDC}`);
|
|
297
|
+
|
|
298
|
+
const potentialLeaks = {};
|
|
299
|
+
for (const [fname, content] of Object.entries(diffMap)) {
|
|
300
|
+
const suspects = scanForSecrets(content);
|
|
301
|
+
if (suspects.length > 0) potentialLeaks[fname] = suspects;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (Object.keys(potentialLeaks).length > 0) {
|
|
305
|
+
console.log(`\n${Colors.WARNING}⚠️ Entropie élevée détectée ! Analyse approfondie par l'IA...${Colors.ENDC}`);
|
|
306
|
+
const valPrompt = `Analyse ces secrets potentiels : ${JSON.stringify(potentialLeaks)}. Réponds JSON {'is_breach': bool, 'reason': str}. Vrais secrets (API Keys) seulement.`;
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const res = await ai.call("Tu es un expert sécurité.", valPrompt, 0.3, true);
|
|
310
|
+
const audit = JSON.parse(res);
|
|
311
|
+
|
|
312
|
+
if (audit.is_breach) {
|
|
313
|
+
console.log(`\n${Colors.FAIL}❌ [BLOCAGE SÉCURITÉ] Secret détecté !${Colors.ENDC}`);
|
|
314
|
+
console.log(`${Colors.FAIL} Raison : ${audit.reason}${Colors.ENDC}\n`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
} else {
|
|
317
|
+
console.log(`${Colors.GREEN}✅ Faux positifs confirmés (Sûr).${Colors.ENDC}`);
|
|
318
|
+
}
|
|
319
|
+
} catch (e) {
|
|
320
|
+
console.log(`${Colors.FAIL}Erreur audit IA: ${e.message}${Colors.ENDC}`);
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
console.log(`${Colors.GREEN}OK (Code sain)${Colors.ENDC}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 4. Génération
|
|
327
|
+
const tokensEstimates = Math.ceil(fullDiffText.length / 4);
|
|
328
|
+
console.log(`${Colors.BLUE}⚡ [2/2] Génération du message... ${Colors.DIM}(~${tokensEstimates} tokens)${Colors.ENDC}`);
|
|
329
|
+
console.log(`${Colors.DIM} Modèle utilisé : ${model}${Colors.ENDC}`);
|
|
330
|
+
|
|
331
|
+
const sysPrompt = "Tu es un assistant Git expert. Génère UNIQUEMENT un message de commit suivant la convention 'Conventional Commits' (ex: feat: add login). Sois concis, descriptif et professionnel. N'utilise pas de markdown (pas de backticks), pas de guillemets autour du message.";
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
let commitMsg = await ai.call(sysPrompt, `Diff :\n${fullDiffText.substring(0, 12000)}`);
|
|
335
|
+
commitMsg = commitMsg.trim().replace(/^['"`]|['"`]$/g, ''); // Nettoyage final
|
|
336
|
+
|
|
337
|
+
console.log(`\n${Colors.CYAN}──────────────────────────────────────────────────${Colors.ENDC}`);
|
|
338
|
+
console.log(`${Colors.BOLD}${commitMsg}${Colors.ENDC}`);
|
|
339
|
+
console.log(`${Colors.CYAN}──────────────────────────────────────────────────${Colors.ENDC}\n`);
|
|
340
|
+
|
|
341
|
+
const action = await promptUser(`${Colors.BOLD}[Enter]${Colors.ENDC} Valider | ${Colors.BOLD}[n]${Colors.ENDC} Annuler : `);
|
|
342
|
+
|
|
343
|
+
if (action.toLowerCase() === 'n') {
|
|
344
|
+
console.log(`\n${Colors.WARNING}🚫 Opération annulée.${Colors.ENDC}\n`);
|
|
345
|
+
} else {
|
|
346
|
+
try {
|
|
347
|
+
execSync(`git commit -m "${commitMsg}"`, { stdio: 'inherit' });
|
|
348
|
+
console.log(`\n${Colors.GREEN}✅ Commit effectué avec succès !${Colors.ENDC}\n`);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
// L'erreur est déjà affichée par git via stdio: inherit
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch (e) {
|
|
354
|
+
console.log(`\n${Colors.FAIL}❌ Erreur fatale : ${e.message}${Colors.ENDC}\n`);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (require.main === module) {
|
|
360
|
+
main().catch(console.error);
|
|
361
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "atlasia-ghost",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Utilitaire de verification, validation, et de proposition de commits basé sur l'IA",
|
|
5
|
+
"main": "ghost.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ghost": "./ghost.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"ghost raider\"",
|
|
11
|
+
"start": "node ghost.js"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/lamallamadel/ghost.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"git",
|
|
19
|
+
"commit",
|
|
20
|
+
"ghost",
|
|
21
|
+
"automate",
|
|
22
|
+
"ai",
|
|
23
|
+
"cli",
|
|
24
|
+
"devsecops"
|
|
25
|
+
],
|
|
26
|
+
"author": "Adel Lamallam",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/lamallamadel/ghost/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/lamallamadel/ghost#readme",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=14.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|