@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.
Files changed (71) hide show
  1. package/.editorconfig +38 -0
  2. package/.gitattributes +18 -0
  3. package/.nvmrc +1 -0
  4. package/README.md +179 -0
  5. package/bin/shiva.js +4 -0
  6. package/package.json +44 -0
  7. package/recipes/full-rp.json +77 -0
  8. package/recipes/minimal.json +30 -0
  9. package/recipes/standard.json +46 -0
  10. package/src/commands/ai/context.js +89 -0
  11. package/src/commands/ai/link.js +38 -0
  12. package/src/commands/ai/mcp.js +39 -0
  13. package/src/commands/config/validate.js +65 -0
  14. package/src/commands/docs/api.js +81 -0
  15. package/src/commands/docs/build.js +14 -0
  16. package/src/commands/docs/deploy.js +14 -0
  17. package/src/commands/docs/serve.js +14 -0
  18. package/src/commands/init.js +167 -0
  19. package/src/commands/install.js +108 -0
  20. package/src/commands/locale/missing.js +83 -0
  21. package/src/commands/make/contract.js +45 -0
  22. package/src/commands/make/migration.js +69 -0
  23. package/src/commands/make/model.js +63 -0
  24. package/src/commands/make/module.js +115 -0
  25. package/src/commands/make/seed.js +51 -0
  26. package/src/commands/make/service.js +60 -0
  27. package/src/commands/make/test.js +53 -0
  28. package/src/commands/mcp.js +26 -0
  29. package/src/commands/migrate/rollback.js +155 -0
  30. package/src/commands/migrate/run.js +159 -0
  31. package/src/commands/migrate/status.js +137 -0
  32. package/src/commands/module/list.js +46 -0
  33. package/src/commands/module/status.js +64 -0
  34. package/src/commands/outdated.js +59 -0
  35. package/src/commands/remove.js +61 -0
  36. package/src/commands/seed.js +108 -0
  37. package/src/commands/test.js +88 -0
  38. package/src/commands/update.js +90 -0
  39. package/src/generators/index.js +78 -0
  40. package/src/generators/templates/contract.lua.tpl +12 -0
  41. package/src/generators/templates/migration.lua.tpl +15 -0
  42. package/src/generators/templates/model.lua.tpl +14 -0
  43. package/src/generators/templates/module/client/init.lua.tpl +5 -0
  44. package/src/generators/templates/module/config/config.lua.tpl +4 -0
  45. package/src/generators/templates/module/fxmanifest.lua.tpl +41 -0
  46. package/src/generators/templates/module/locales/en.lua.tpl +4 -0
  47. package/src/generators/templates/module/module.lua.tpl +10 -0
  48. package/src/generators/templates/module/server/init.lua.tpl +2 -0
  49. package/src/generators/templates/module/shared/init.lua.tpl +5 -0
  50. package/src/generators/templates/seed.lua.tpl +10 -0
  51. package/src/generators/templates/service.lua.tpl +7 -0
  52. package/src/generators/templates/test.lua.tpl +39 -0
  53. package/src/index.js +113 -0
  54. package/src/mcp/resources/contracts.js +68 -0
  55. package/src/mcp/resources/docs.js +56 -0
  56. package/src/mcp/resources/examples.js +235 -0
  57. package/src/mcp/server.js +121 -0
  58. package/src/mcp/tools/config.js +53 -0
  59. package/src/mcp/tools/contracts.js +37 -0
  60. package/src/mcp/tools/database.js +93 -0
  61. package/src/mcp/tools/docs.js +38 -0
  62. package/src/mcp/tools/events.js +26 -0
  63. package/src/mcp/tools/items.js +280 -0
  64. package/src/mcp/tools/modules.js +25 -0
  65. package/src/packages/lockfile.js +53 -0
  66. package/src/packages/registry.js +99 -0
  67. package/src/packages/resolver.js +83 -0
  68. package/src/utils/config-reader.js +74 -0
  69. package/src/utils/lua-annotations.js +319 -0
  70. package/src/utils/lua-parser.js +119 -0
  71. 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 };