@shiva-fw/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/.editorconfig +38 -0
- package/.gitattributes +18 -0
- package/.nvmrc +1 -0
- package/README.md +179 -0
- package/bin/shiva.js +4 -0
- package/package.json +44 -0
- package/recipes/full-rp.json +77 -0
- package/recipes/minimal.json +30 -0
- package/recipes/standard.json +46 -0
- package/src/commands/ai/context.js +89 -0
- package/src/commands/ai/link.js +38 -0
- package/src/commands/ai/mcp.js +39 -0
- package/src/commands/config/validate.js +65 -0
- package/src/commands/docs/api.js +81 -0
- package/src/commands/docs/build.js +14 -0
- package/src/commands/docs/deploy.js +14 -0
- package/src/commands/docs/serve.js +14 -0
- package/src/commands/init.js +167 -0
- package/src/commands/install.js +108 -0
- package/src/commands/locale/missing.js +83 -0
- package/src/commands/make/contract.js +45 -0
- package/src/commands/make/migration.js +69 -0
- package/src/commands/make/model.js +63 -0
- package/src/commands/make/module.js +115 -0
- package/src/commands/make/seed.js +51 -0
- package/src/commands/make/service.js +60 -0
- package/src/commands/make/test.js +53 -0
- package/src/commands/mcp.js +26 -0
- package/src/commands/migrate/rollback.js +155 -0
- package/src/commands/migrate/run.js +159 -0
- package/src/commands/migrate/status.js +137 -0
- package/src/commands/module/list.js +46 -0
- package/src/commands/module/status.js +64 -0
- package/src/commands/outdated.js +59 -0
- package/src/commands/remove.js +61 -0
- package/src/commands/seed.js +108 -0
- package/src/commands/test.js +88 -0
- package/src/commands/update.js +90 -0
- package/src/generators/index.js +78 -0
- package/src/generators/templates/contract.lua.tpl +12 -0
- package/src/generators/templates/migration.lua.tpl +15 -0
- package/src/generators/templates/model.lua.tpl +14 -0
- package/src/generators/templates/module/client/init.lua.tpl +5 -0
- package/src/generators/templates/module/config/config.lua.tpl +4 -0
- package/src/generators/templates/module/fxmanifest.lua.tpl +41 -0
- package/src/generators/templates/module/locales/en.lua.tpl +4 -0
- package/src/generators/templates/module/module.lua.tpl +10 -0
- package/src/generators/templates/module/server/init.lua.tpl +2 -0
- package/src/generators/templates/module/shared/init.lua.tpl +5 -0
- package/src/generators/templates/seed.lua.tpl +10 -0
- package/src/generators/templates/service.lua.tpl +7 -0
- package/src/generators/templates/test.lua.tpl +39 -0
- package/src/index.js +113 -0
- package/src/mcp/resources/contracts.js +68 -0
- package/src/mcp/resources/docs.js +56 -0
- package/src/mcp/resources/examples.js +235 -0
- package/src/mcp/server.js +121 -0
- package/src/mcp/tools/config.js +53 -0
- package/src/mcp/tools/contracts.js +37 -0
- package/src/mcp/tools/database.js +93 -0
- package/src/mcp/tools/docs.js +38 -0
- package/src/mcp/tools/events.js +26 -0
- package/src/mcp/tools/items.js +280 -0
- package/src/mcp/tools/modules.js +25 -0
- package/src/packages/lockfile.js +53 -0
- package/src/packages/registry.js +99 -0
- package/src/packages/resolver.js +83 -0
- package/src/utils/config-reader.js +74 -0
- package/src/utils/lua-annotations.js +319 -0
- package/src/utils/lua-parser.js +119 -0
- package/src/utils/server-root.js +66 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { scanModules } = require('../../utils/lua-parser');
|
|
7
|
+
const { getResourcesDir } = require('../../utils/server-root');
|
|
8
|
+
|
|
9
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recursively collect all .lua files under a directory.
|
|
13
|
+
* @param {string} dir
|
|
14
|
+
* @param {string[]} [results]
|
|
15
|
+
* @returns {string[]}
|
|
16
|
+
*/
|
|
17
|
+
function collectLuaFiles(dir, results = []) {
|
|
18
|
+
if (!fs.existsSync(dir)) return results;
|
|
19
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
20
|
+
const full = path.join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
collectLuaFiles(full, results);
|
|
23
|
+
} else if (entry.isFile() && entry.name.endsWith('.lua')) {
|
|
24
|
+
results.push(full);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract inline string values from a Lua table field, e.g.
|
|
32
|
+
* label = 'Bread' → 'Bread'
|
|
33
|
+
* @param {string} block
|
|
34
|
+
* @param {string} field
|
|
35
|
+
* @returns {string|null}
|
|
36
|
+
*/
|
|
37
|
+
function extractField(block, field) {
|
|
38
|
+
const re = new RegExp(`${field}\\s*=\\s*['"]([^'"]+)['"]`);
|
|
39
|
+
const match = block.match(re);
|
|
40
|
+
return match ? match[1] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract a numeric field from a Lua table block.
|
|
45
|
+
* @param {string} block
|
|
46
|
+
* @param {string} field
|
|
47
|
+
* @returns {number|null}
|
|
48
|
+
*/
|
|
49
|
+
function extractNumber(block, field) {
|
|
50
|
+
const re = new RegExp(`${field}\\s*=\\s*(\\d+(?:\\.\\d+)?)`);
|
|
51
|
+
const match = block.match(re);
|
|
52
|
+
return match ? parseFloat(match[1]) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Tools ────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const getItemDefinitions = {
|
|
58
|
+
name: 'shiva:getItemDefinitions',
|
|
59
|
+
description: 'Get all registered inventory item definitions from installed modules',
|
|
60
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
61
|
+
|
|
62
|
+
async handler(_, serverRoot) {
|
|
63
|
+
if (!serverRoot) return 'Not in a Shiva project directory.';
|
|
64
|
+
|
|
65
|
+
const modules = scanModules(getResourcesDir(serverRoot));
|
|
66
|
+
const items = [];
|
|
67
|
+
|
|
68
|
+
for (const mod of modules) {
|
|
69
|
+
const luaFiles = collectLuaFiles(mod.path);
|
|
70
|
+
for (const file of luaFiles) {
|
|
71
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
72
|
+
|
|
73
|
+
// Pattern 1: Config.Items['key'] = { label = '...', weight = ..., type = '...' }
|
|
74
|
+
const cfgItemRe = /Config\.Items\s*\[\s*['"]([^'"]+)['"]\s*\]\s*=\s*\{([^}]+)\}/g;
|
|
75
|
+
let m;
|
|
76
|
+
while ((m = cfgItemRe.exec(content)) !== null) {
|
|
77
|
+
const [, name, body] = m;
|
|
78
|
+
items.push({
|
|
79
|
+
name,
|
|
80
|
+
module: mod.name,
|
|
81
|
+
label: extractField(body, 'label'),
|
|
82
|
+
weight: extractNumber(body, 'weight'),
|
|
83
|
+
type: extractField(body, 'type') || 'item',
|
|
84
|
+
usable: /usable\s*=\s*true/.test(body),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Pattern 2: items['key'] = { ... } (local table)
|
|
89
|
+
const localItemRe = /\bitems\s*\[\s*['"]([^'"]+)['"]\s*\]\s*=\s*\{([^}]+)\}/g;
|
|
90
|
+
while ((m = localItemRe.exec(content)) !== null) {
|
|
91
|
+
const [, name, body] = m;
|
|
92
|
+
if (items.find(i => i.name === name && i.module === mod.name)) continue;
|
|
93
|
+
items.push({
|
|
94
|
+
name,
|
|
95
|
+
module: mod.name,
|
|
96
|
+
label: extractField(body, 'label'),
|
|
97
|
+
weight: extractNumber(body, 'weight'),
|
|
98
|
+
type: extractField(body, 'type') || 'item',
|
|
99
|
+
usable: /usable\s*=\s*true/.test(body),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pattern 3: Inventory.registerItem('key', { ... }) or Item.register(...)
|
|
104
|
+
const registerRe = /(?:Inventory\.registerItem|Item\.register)\s*\(\s*['"]([^'"]+)['"]\s*,\s*\{([^}]+)\}/g;
|
|
105
|
+
while ((m = registerRe.exec(content)) !== null) {
|
|
106
|
+
const [, name, body] = m;
|
|
107
|
+
if (items.find(i => i.name === name && i.module === mod.name)) continue;
|
|
108
|
+
items.push({
|
|
109
|
+
name,
|
|
110
|
+
module: mod.name,
|
|
111
|
+
label: extractField(body, 'label'),
|
|
112
|
+
weight: extractNumber(body, 'weight'),
|
|
113
|
+
type: extractField(body, 'type') || 'item',
|
|
114
|
+
usable: /usable\s*=\s*true/.test(body),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (items.length === 0) return 'No item definitions found in installed modules.';
|
|
121
|
+
return items;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const getJobDefinitions = {
|
|
126
|
+
name: 'shiva:getJobDefinitions',
|
|
127
|
+
description: 'Get all registered job definitions from installed modules',
|
|
128
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
129
|
+
|
|
130
|
+
async handler(_, serverRoot) {
|
|
131
|
+
if (!serverRoot) return 'Not in a Shiva project directory.';
|
|
132
|
+
|
|
133
|
+
const modules = scanModules(getResourcesDir(serverRoot));
|
|
134
|
+
const jobs = [];
|
|
135
|
+
|
|
136
|
+
for (const mod of modules) {
|
|
137
|
+
const luaFiles = collectLuaFiles(mod.path);
|
|
138
|
+
for (const file of luaFiles) {
|
|
139
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
140
|
+
|
|
141
|
+
// Pattern 1: Config.Jobs['key'] = { label = '...', grades = {...} }
|
|
142
|
+
const cfgJobRe = /Config\.Jobs\s*\[\s*['"]([^'"]+)['"]\s*\]\s*=\s*\{([\s\S]*?)(?=\n\s*\}[\s\n]*(?:--|\[|Config|$))/g;
|
|
143
|
+
let m;
|
|
144
|
+
while ((m = cfgJobRe.exec(content)) !== null) {
|
|
145
|
+
const [, name, body] = m;
|
|
146
|
+
const grades = [];
|
|
147
|
+
const gradeRe = /\[(\d+)\]\s*=\s*\{([^}]+)\}/g;
|
|
148
|
+
let gm;
|
|
149
|
+
while ((gm = gradeRe.exec(body)) !== null) {
|
|
150
|
+
grades.push({
|
|
151
|
+
grade: parseInt(gm[1], 10),
|
|
152
|
+
label: extractField(gm[2], 'label'),
|
|
153
|
+
salary: extractNumber(gm[2], 'salary'),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
jobs.push({ name, module: mod.name, label: extractField(body, 'label'), grades });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Pattern 2: Job.register('key', { ... }) or Jobs.register(...)
|
|
160
|
+
const registerRe = /(?:Job\.register|Jobs\.register)\s*\(\s*['"]([^'"]+)['"]\s*,\s*\{([^}]+)\}/g;
|
|
161
|
+
while ((m = registerRe.exec(content)) !== null) {
|
|
162
|
+
const [, name, body] = m;
|
|
163
|
+
if (jobs.find(j => j.name === name && j.module === mod.name)) continue;
|
|
164
|
+
jobs.push({ name, module: mod.name, label: extractField(body, 'label'), grades: [] });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (jobs.length === 0) return 'No job definitions found in installed modules.';
|
|
170
|
+
return jobs;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const getAvailableCommands = {
|
|
175
|
+
name: 'shiva:getAvailableCommands',
|
|
176
|
+
description: 'Get all registered commands from installed modules',
|
|
177
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
178
|
+
|
|
179
|
+
async handler(_, serverRoot) {
|
|
180
|
+
if (!serverRoot) return 'Not in a Shiva project directory.';
|
|
181
|
+
|
|
182
|
+
const modules = scanModules(getResourcesDir(serverRoot));
|
|
183
|
+
const commands = [];
|
|
184
|
+
|
|
185
|
+
for (const mod of modules) {
|
|
186
|
+
const luaFiles = collectLuaFiles(mod.path);
|
|
187
|
+
for (const file of luaFiles) {
|
|
188
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
189
|
+
const side = path.basename(file).startsWith('sv_') ? 'server'
|
|
190
|
+
: path.basename(file).startsWith('cl_') ? 'client'
|
|
191
|
+
: 'shared';
|
|
192
|
+
|
|
193
|
+
// Pattern 1: Commands.register('name', { description, permission, params }, fn)
|
|
194
|
+
const cmdRe = /Commands\.register\s*\(\s*['"]([^'"]+)['"]\s*,\s*\{([^}]*)\}/g;
|
|
195
|
+
let m;
|
|
196
|
+
while ((m = cmdRe.exec(content)) !== null) {
|
|
197
|
+
const [, name, body] = m;
|
|
198
|
+
commands.push({
|
|
199
|
+
name,
|
|
200
|
+
module: mod.name,
|
|
201
|
+
side,
|
|
202
|
+
description: extractField(body, 'description'),
|
|
203
|
+
permission: extractField(body, 'permission'),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Pattern 2: RegisterCommand('name', fn, restricted)
|
|
208
|
+
const regCmdRe = /RegisterCommand\s*\(\s*['"]([^'"]+)['"]/g;
|
|
209
|
+
while ((m = regCmdRe.exec(content)) !== null) {
|
|
210
|
+
const [, name] = m;
|
|
211
|
+
if (commands.find(c => c.name === name && c.module === mod.name)) continue;
|
|
212
|
+
commands.push({ name, module: mod.name, side, description: null, permission: null });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (commands.length === 0) return 'No commands found in installed modules.';
|
|
218
|
+
return commands;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const getServiceMethods = {
|
|
223
|
+
name: 'shiva:getServiceMethods',
|
|
224
|
+
description: 'Get all service methods from installed modules (including contract extensions)',
|
|
225
|
+
inputSchema: {
|
|
226
|
+
type: 'object',
|
|
227
|
+
properties: {
|
|
228
|
+
service: { type: 'string', description: 'Service/contract name to filter, e.g. Economy. Omit for all.' },
|
|
229
|
+
},
|
|
230
|
+
required: [],
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async handler({ service } = {}, serverRoot) {
|
|
234
|
+
if (!serverRoot) return 'Not in a Shiva project directory.';
|
|
235
|
+
|
|
236
|
+
const modules = scanModules(getResourcesDir(serverRoot));
|
|
237
|
+
const services = {};
|
|
238
|
+
|
|
239
|
+
for (const mod of modules) {
|
|
240
|
+
const luaFiles = collectLuaFiles(path.join(mod.path, 'server')).concat(
|
|
241
|
+
collectLuaFiles(path.join(mod.path, 'shared'))
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
for (const file of luaFiles) {
|
|
245
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
246
|
+
|
|
247
|
+
// Pattern 1: Container.register('ServiceName', { method = function ... })
|
|
248
|
+
const regRe = /Container\.register\s*\(\s*['"]([^'"]+)['"]/g;
|
|
249
|
+
let m;
|
|
250
|
+
while ((m = regRe.exec(content)) !== null) {
|
|
251
|
+
const svcName = m[1];
|
|
252
|
+
if (!services[svcName]) services[svcName] = { module: mod.name, methods: [] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Pattern 2: ServiceName.methodName = function(...) or function ServiceName.methodName(...)
|
|
256
|
+
const methodRe = /(?:^|\n)\s*(?:function\s+(\w+)\.(\w+)\s*\(|(\w+)\.(\w+)\s*=\s*function\s*\()/g;
|
|
257
|
+
while ((m = methodRe.exec(content)) !== null) {
|
|
258
|
+
const svcName = m[1] || m[3];
|
|
259
|
+
const methodName = m[2] || m[4];
|
|
260
|
+
if (!svcName || methodName === 'new') continue;
|
|
261
|
+
if (service && svcName.toLowerCase() !== service.toLowerCase()) continue;
|
|
262
|
+
if (!services[svcName]) services[svcName] = { module: mod.name, methods: [] };
|
|
263
|
+
if (!services[svcName].methods.includes(methodName)) {
|
|
264
|
+
services[svcName].methods.push(methodName);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (service) {
|
|
271
|
+
const key = Object.keys(services).find(k => k.toLowerCase() === service.toLowerCase());
|
|
272
|
+
return key ? { [key]: services[key] } : `Service not found: ${service}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (Object.keys(services).length === 0) return 'No services found in installed modules.';
|
|
276
|
+
return services;
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
module.exports = [getItemDefinitions, getJobDefinitions, getAvailableCommands, getServiceMethods];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanModules } = require('../../utils/lua-parser');
|
|
4
|
+
const { getResourcesDir } = require('../../utils/server-root');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
name: 'shiva:getInstalledModules',
|
|
8
|
+
description: 'List all installed Shiva modules with version, dependencies, and provided contracts',
|
|
9
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
10
|
+
|
|
11
|
+
async handler(_, serverRoot) {
|
|
12
|
+
if (!serverRoot) return 'Not in a Shiva project directory.';
|
|
13
|
+
const modules = scanModules(getResourcesDir(serverRoot));
|
|
14
|
+
if (modules.length === 0) return 'No Shiva modules found in resources/[shiva]/';
|
|
15
|
+
return modules.map(m => ({
|
|
16
|
+
name: m.name,
|
|
17
|
+
version: m.manifest.version,
|
|
18
|
+
description: m.manifest.description,
|
|
19
|
+
dependencies: m.manifest.dependencies || [],
|
|
20
|
+
provides: m.manifest.provides || [],
|
|
21
|
+
events: m.manifest.events || [],
|
|
22
|
+
migrations: (m.manifest.migrations || []).length,
|
|
23
|
+
}));
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
const LOCKFILE_NAME = 'shiva.lock';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read the lockfile from server root.
|
|
10
|
+
* @param {string} serverRoot
|
|
11
|
+
* @returns {object}
|
|
12
|
+
*/
|
|
13
|
+
function readLockfile(serverRoot) {
|
|
14
|
+
const p = path.join(serverRoot, LOCKFILE_NAME);
|
|
15
|
+
if (!fs.existsSync(p)) return { version: 1, modules: {} };
|
|
16
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return { version: 1, modules: {} }; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Write the lockfile.
|
|
21
|
+
* @param {string} serverRoot
|
|
22
|
+
* @param {object} data
|
|
23
|
+
*/
|
|
24
|
+
function writeLockfile(serverRoot, data) {
|
|
25
|
+
const p = path.join(serverRoot, LOCKFILE_NAME);
|
|
26
|
+
data.updatedAt = new Date().toISOString();
|
|
27
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Update a single module entry in the lockfile.
|
|
32
|
+
* @param {string} serverRoot
|
|
33
|
+
* @param {string} name
|
|
34
|
+
* @param {object} entry { version, resolved, integrity? }
|
|
35
|
+
*/
|
|
36
|
+
function lockModule(serverRoot, name, entry) {
|
|
37
|
+
const lock = readLockfile(serverRoot);
|
|
38
|
+
lock.modules[name] = { ...entry, lockedAt: new Date().toISOString() };
|
|
39
|
+
writeLockfile(serverRoot, lock);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Remove a module from the lockfile.
|
|
44
|
+
* @param {string} serverRoot
|
|
45
|
+
* @param {string} name
|
|
46
|
+
*/
|
|
47
|
+
function unlockModule(serverRoot, name) {
|
|
48
|
+
const lock = readLockfile(serverRoot);
|
|
49
|
+
delete lock.modules[name];
|
|
50
|
+
writeLockfile(serverRoot, lock);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { readLockfile, writeLockfile, lockModule, unlockModule };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_REGISTRY = 'https://registry.shiva.dev';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get registry URL from config or default.
|
|
12
|
+
* @param {object} config shiva.json contents
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function getRegistryUrl(config) {
|
|
16
|
+
return (config && config.registry) || DEFAULT_REGISTRY;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Perform a simple GET request returning parsed JSON.
|
|
21
|
+
* @param {string} url
|
|
22
|
+
* @returns {Promise<object>}
|
|
23
|
+
*/
|
|
24
|
+
function fetchJson(url) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const mod = url.startsWith('https') ? https : http;
|
|
27
|
+
mod.get(url, { headers: { 'User-Agent': 'shiva-cli/1.0', 'Accept': 'application/json' } }, (res) => {
|
|
28
|
+
let data = '';
|
|
29
|
+
res.on('data', chunk => { data += chunk; });
|
|
30
|
+
res.on('end', () => {
|
|
31
|
+
if (res.statusCode >= 400) {
|
|
32
|
+
reject(new Error(`Registry returned ${res.statusCode} for ${url}`));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try { resolve(JSON.parse(data)); } catch (e) { reject(new Error(`Invalid JSON from registry: ${e.message}`)); }
|
|
36
|
+
});
|
|
37
|
+
}).on('error', reject);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fetch available versions for a module from the registry.
|
|
43
|
+
* @param {string} registryUrl
|
|
44
|
+
* @param {string} name
|
|
45
|
+
* @returns {Promise<string[]>}
|
|
46
|
+
*/
|
|
47
|
+
async function fetchVersions(registryUrl, name) {
|
|
48
|
+
const data = await fetchJson(`${registryUrl}/modules/${encodeURIComponent(name)}`);
|
|
49
|
+
return data.versions || [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetch module metadata (including deps) for a specific version.
|
|
54
|
+
* @param {string} registryUrl
|
|
55
|
+
* @param {string} name
|
|
56
|
+
* @param {string} version
|
|
57
|
+
* @returns {Promise<object>} { name, version, dependencies, downloadUrl, integrity }
|
|
58
|
+
*/
|
|
59
|
+
async function fetchModuleMeta(registryUrl, name, version) {
|
|
60
|
+
return fetchJson(`${registryUrl}/modules/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Download a module tarball and extract it to destDir.
|
|
65
|
+
* @param {string} downloadUrl
|
|
66
|
+
* @param {string} destDir
|
|
67
|
+
* @returns {Promise<void>}
|
|
68
|
+
*/
|
|
69
|
+
async function downloadModule(downloadUrl, destDir) {
|
|
70
|
+
const { pipeline } = require('stream/promises');
|
|
71
|
+
const zlib = require('zlib');
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const mod = downloadUrl.startsWith('https') ? https : http;
|
|
75
|
+
mod.get(downloadUrl, async (res) => {
|
|
76
|
+
if (res.statusCode >= 400) {
|
|
77
|
+
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
82
|
+
try {
|
|
83
|
+
const tar = require('tar');
|
|
84
|
+
await pipeline(res, zlib.createGunzip(), tar.extract({ cwd: destDir, strip: 1 }));
|
|
85
|
+
} catch {
|
|
86
|
+
// Fallback: save raw bytes when tar is not available
|
|
87
|
+
const outPath = path.join(destDir, '_downloaded.tar.gz');
|
|
88
|
+
const out = fs.createWriteStream(outPath);
|
|
89
|
+
await pipeline(res, out);
|
|
90
|
+
}
|
|
91
|
+
resolve();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
reject(e);
|
|
94
|
+
}
|
|
95
|
+
}).on('error', reject);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { getRegistryUrl, fetchJson, fetchVersions, fetchModuleMeta, downloadModule };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const semver = require('semver');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a version satisfies a constraint.
|
|
7
|
+
* @param {string} version
|
|
8
|
+
* @param {string} constraint e.g. "^1.0.0", "~1.2.0", ">=1.0.0 <2.0.0"
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
function satisfies(version, constraint) {
|
|
12
|
+
if (!constraint || constraint === '*' || constraint === 'latest') return true;
|
|
13
|
+
if (constraint.startsWith('file:')) return true;
|
|
14
|
+
try {
|
|
15
|
+
return semver.satisfies(version, constraint);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Find the best matching version from a list given a constraint.
|
|
23
|
+
* @param {string[]} versions Sorted list of available versions
|
|
24
|
+
* @param {string} constraint
|
|
25
|
+
* @returns {string|null}
|
|
26
|
+
*/
|
|
27
|
+
function resolveVersion(versions, constraint) {
|
|
28
|
+
if (!constraint || constraint === 'latest') {
|
|
29
|
+
return versions[versions.length - 1] || null;
|
|
30
|
+
}
|
|
31
|
+
if (constraint.startsWith('file:')) return constraint;
|
|
32
|
+
|
|
33
|
+
const valid = versions
|
|
34
|
+
.filter(v => semver.valid(v) && semver.satisfies(v, constraint))
|
|
35
|
+
.sort(semver.compare);
|
|
36
|
+
|
|
37
|
+
return valid[valid.length - 1] || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a flat install plan by resolving transitive dependencies.
|
|
42
|
+
* @param {object} directDeps { name: constraint }
|
|
43
|
+
* @param {Function} fetchVersions async (name) => string[]
|
|
44
|
+
* @param {Function} fetchDeps async (name, version) => { name: constraint }
|
|
45
|
+
* @returns {Promise<Map<string, string>>} name → resolved version
|
|
46
|
+
*/
|
|
47
|
+
async function buildInstallPlan(directDeps, fetchVersions, fetchDeps) {
|
|
48
|
+
const plan = new Map();
|
|
49
|
+
const queue = Object.entries(directDeps);
|
|
50
|
+
const visited = new Set();
|
|
51
|
+
|
|
52
|
+
while (queue.length > 0) {
|
|
53
|
+
const [name, constraint] = queue.shift();
|
|
54
|
+
|
|
55
|
+
if (visited.has(name)) continue;
|
|
56
|
+
visited.add(name);
|
|
57
|
+
|
|
58
|
+
if (constraint.startsWith('file:')) {
|
|
59
|
+
plan.set(name, constraint);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const versions = await fetchVersions(name);
|
|
64
|
+
const resolved = resolveVersion(versions, constraint);
|
|
65
|
+
|
|
66
|
+
if (!resolved) {
|
|
67
|
+
throw new Error(`Cannot resolve ${name}@${constraint} — no matching version found.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
plan.set(name, resolved);
|
|
71
|
+
|
|
72
|
+
const transitive = await fetchDeps(name, resolved);
|
|
73
|
+
for (const [depName, depConstraint] of Object.entries(transitive || {})) {
|
|
74
|
+
if (!visited.has(depName)) {
|
|
75
|
+
queue.push([depName, depConstraint]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return plan;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { satisfies, resolveVersion, buildInstallPlan };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read shiva.json from the given server root.
|
|
8
|
+
* @param {string} serverRoot
|
|
9
|
+
* @returns {object}
|
|
10
|
+
*/
|
|
11
|
+
function readShivaConfig(serverRoot) {
|
|
12
|
+
const configPath = path.join(serverRoot, 'shiva.json');
|
|
13
|
+
if (!fs.existsSync(configPath)) {
|
|
14
|
+
throw new Error(`shiva.json not found at ${configPath}`);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new Error(`Failed to parse shiva.json: ${err.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write shiva.json to the given server root.
|
|
25
|
+
* @param {string} serverRoot
|
|
26
|
+
* @param {object} config
|
|
27
|
+
*/
|
|
28
|
+
function writeShivaConfig(serverRoot, config) {
|
|
29
|
+
const configPath = path.join(serverRoot, 'shiva.json');
|
|
30
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read shiva.lock from the given server root.
|
|
35
|
+
* @param {string} serverRoot
|
|
36
|
+
* @returns {object}
|
|
37
|
+
*/
|
|
38
|
+
function readLockfile(serverRoot) {
|
|
39
|
+
const lockPath = path.join(serverRoot, 'shiva.lock');
|
|
40
|
+
if (!fs.existsSync(lockPath)) {
|
|
41
|
+
return { version: 1, modules: {} };
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return { version: 1, modules: {} };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write shiva.lock to the given server root.
|
|
52
|
+
* @param {string} serverRoot
|
|
53
|
+
* @param {object} lockData
|
|
54
|
+
*/
|
|
55
|
+
function writeLockfile(serverRoot, lockData) {
|
|
56
|
+
const lockPath = path.join(serverRoot, 'shiva.lock');
|
|
57
|
+
fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + '\n', 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get database connection config from shiva.json.
|
|
62
|
+
* @param {string} serverRoot
|
|
63
|
+
* @returns {object|null}
|
|
64
|
+
*/
|
|
65
|
+
function getDatabaseConfig(serverRoot) {
|
|
66
|
+
try {
|
|
67
|
+
const config = readShivaConfig(serverRoot);
|
|
68
|
+
return config.database || null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { readShivaConfig, writeShivaConfig, readLockfile, writeLockfile, getDatabaseConfig };
|