screepsmod-exec-cli-in-console 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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -0
  3. package/README.zh.md +122 -0
  4. package/index.js +698 -0
  5. package/package.json +27 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lovezhangchuangxin
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
@@ -0,0 +1,122 @@
1
+ # screepsmod-exec-cli-in-console
2
+
3
+ [English](README.md) | [中文](README.zh.md)
4
+
5
+ Allow players to execute **(some)** Screeps server **CLI-only** commands from the **in-game console**.
6
+
7
+ This mod injects `Game.cli.exec(code)` into the player sandbox, queues the request on the server, executes the code inside the server CLI sandbox, and streams output back to the player console.
8
+
9
+ ## Features
10
+
11
+ - Run server CLI JS from in-game console: `Game.cli.exec('help()')`
12
+ - Built-in helpers:
13
+ - `Game.cli.help()`
14
+ - `Game.cli.setStore(target, store)`
15
+ - `Game.cli.setStoreHuge(target)`
16
+ - `Game.cli.setControllerLevel(target, level)`
17
+ - `Game.cli.finishConstructionSites(rooms)`
18
+ - **Security-first** by default:
19
+ - **Deny by default** (you must explicitly allow users)
20
+ - Two roles: **normal** and **super admin**
21
+ - Optional `allowedCodePrefixes` allow-list
22
+ - Output length + execution time limits
23
+
24
+ ## Installation
25
+
26
+ Install like a normal Screeps private-server mod:
27
+
28
+ 1. Put this package under your server `mods/` folder (or install it via npm/pnpm/yarn inside your server folder).
29
+ 2. Enable it in your server `mods.json`.
30
+
31
+ Example `mods.json`:
32
+
33
+ ```json
34
+ {
35
+ "mods": [
36
+ "screepsmod-auth",
37
+ "screepsmod-admin-utils",
38
+ "screepsmod-exec-cli-in-console"
39
+ ]
40
+ }
41
+ ```
42
+
43
+ ## Configuration (IMPORTANT)
44
+
45
+ Edit `execute-cli.js` and update `SETTINGS`:
46
+
47
+ - `allowAllUsers`: if `true`, every real player becomes a **normal** user (NOT super admin). Keep `false` unless this is a local/dev server.
48
+ - `normalUserIds` / `normalUsernames`: allow-list for **normal** users when `allowAllUsers=false`.
49
+ - `superAdminUserIds` / `superAdminUsernames`: allow-list for **super** users.
50
+ - `superAdminUsersCodeSelfOnly`: if `true`, even super admins cannot read/modify other users' code in `users.code`.
51
+ - `allowedCodePrefixes`: if non-empty, only code starting with one of these prefixes is allowed.
52
+ - Limits:
53
+ - `maxCodeLength`
54
+ - `maxOutputLines`
55
+ - `evalTimeoutMs`
56
+ - `promiseTimeoutMs`
57
+
58
+ ## Usage (in-game console)
59
+
60
+ ### Quick start
61
+
62
+ ```js
63
+ Game.cli.help()
64
+ Game.cli.exec('help()')
65
+ ```
66
+
67
+ ### Run CLI helpers
68
+
69
+ ```js
70
+ Game.cli.exec('system.getTickDuration()')
71
+ ```
72
+
73
+ ### Update `rooms.objects.store`
74
+
75
+ `target` can be `_id`, `id`, or an object containing `_id`/`id`.
76
+
77
+ ```js
78
+ Game.cli.setStore('679f...67f3', { energy: 1000 })
79
+ Game.cli.setStoreHuge('679f...67f3')
80
+ ```
81
+
82
+ ### Set controller level
83
+
84
+ ```js
85
+ Game.cli.setControllerLevel('67c8...df8', 8)
86
+ ```
87
+
88
+ ### Finish construction sites (almost done)
89
+
90
+ Sets `progress = progressTotal - 1` for construction sites in the given rooms.
91
+
92
+ ```js
93
+ Game.cli.finishConstructionSites(['W1N9'])
94
+ ```
95
+
96
+ ## Security notes
97
+
98
+ Executing arbitrary server CLI JS is powerful:
99
+
100
+ - CLI sandbox can access server internals like `storage.*` and `system.*`.
101
+ - This mod is **DENY BY DEFAULT**. Do not enable it for public servers unless you understand the risks.
102
+
103
+ Role behavior:
104
+
105
+ - **Normal users**: CLI sandbox is restricted:
106
+ - `storage` is replaced with a restricted wrapper (mostly limited to their own rooms/objects)
107
+ - `system/map/bots/strongholds` are removed
108
+ - **Super admins**: full CLI sandbox access (optionally with `users.code` self-only privacy guard)
109
+
110
+ ## Troubleshooting
111
+
112
+ - **`Game.cli` is undefined**:
113
+ - Make sure the mod is enabled in `mods.json` and the server restarted.
114
+ - Ensure your server has `isolated-vm` available (Screeps uses it for player sandbox).
115
+ - **`[cli] denied: not allowed user`**:
116
+ - Add your user id / username to `SETTINGS.normalUserIds/normalUsernames` or `superAdmin*`.
117
+ - **No output / output truncated**:
118
+ - Adjust `maxOutputLines`, `evalTimeoutMs`, `promiseTimeoutMs`.
119
+
120
+ ## License
121
+
122
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,122 @@
1
+ # screepsmod-exec-cli-in-console
2
+
3
+ [English](README.md) | [中文](README.zh.md)
4
+
5
+ 让玩家可以在**游戏内控制台**执行(部分)Screeps 私服的**仅 CLI 可用**命令。
6
+
7
+ 该 mod 会把 `Game.cli.exec(code)` 注入到玩家沙箱中:玩家在控制台调用后,服务端会异步排队执行对应的 CLI JS,并把输出流式回写到玩家控制台。
8
+
9
+ ## 功能
10
+
11
+ - 在游戏内控制台执行服务端 CLI JS:`Game.cli.exec('help()')`
12
+ - 内置便捷函数:
13
+ - `Game.cli.help()`
14
+ - `Game.cli.setStore(target, store)`
15
+ - `Game.cli.setStoreHuge(target)`
16
+ - `Game.cli.setControllerLevel(target, level)`
17
+ - `Game.cli.finishConstructionSites(rooms)`
18
+ - **默认安全**(重要):
19
+ - **默认拒绝**(必须显式放行用户)
20
+ - 两级权限:**普通用户** / **超管**
21
+ - 可选 `allowedCodePrefixes` 前缀白名单
22
+ - 输出行数与执行时间限制
23
+
24
+ ## 安装
25
+
26
+ 按常规 Screeps 私服 mod 的方式安装即可:
27
+
28
+ 1. 将本包放到私服的 `mods/` 目录下(或在私服目录内用 npm/pnpm/yarn 安装)。
29
+ 2. 在 `mods.json` 中启用该 mod。
30
+
31
+ `mods.json` 示例:
32
+
33
+ ```json
34
+ {
35
+ "mods": [
36
+ "screepsmod-auth",
37
+ "screepsmod-admin-utils",
38
+ "screepsmod-exec-cli-in-console"
39
+ ]
40
+ }
41
+ ```
42
+
43
+ ## 配置(非常重要)
44
+
45
+ 请直接编辑 `execute-cli.js` 顶部的 `SETTINGS`:
46
+
47
+ - `allowAllUsers`:若为 `true`,所有真实玩家都会被视为**普通用户**(不是超管)。除非本地/单机测试服,否则不要开。
48
+ - `normalUserIds` / `normalUsernames`:当 `allowAllUsers=false` 时,普通用户白名单。
49
+ - `superAdminUserIds` / `superAdminUsernames`:超管白名单。
50
+ - `superAdminUsersCodeSelfOnly`:若为 `true`,即便是超管也不允许读/改其他用户在 `users.code` 里的代码(隐私保护)。
51
+ - `allowedCodePrefixes`:如果不为空,只允许执行以这些前缀开头的代码字符串。
52
+ - 限制项:
53
+ - `maxCodeLength`
54
+ - `maxOutputLines`
55
+ - `evalTimeoutMs`
56
+ - `promiseTimeoutMs`
57
+
58
+ ## 用法(游戏内控制台)
59
+
60
+ ### 快速开始
61
+
62
+ ```js
63
+ Game.cli.help()
64
+ Game.cli.exec('help()')
65
+ ```
66
+
67
+ ### 执行 CLI helper
68
+
69
+ ```js
70
+ Game.cli.exec('system.getTickDuration()')
71
+ ```
72
+
73
+ ### 修改 `rooms.objects.store`
74
+
75
+ `target` 支持:`_id`、`id`,或带有 `_id/id` 字段的对象。
76
+
77
+ ```js
78
+ Game.cli.setStore('679f...67f3', { energy: 1000 })
79
+ Game.cli.setStoreHuge('679f...67f3')
80
+ ```
81
+
82
+ ### 设置房间控制器等级
83
+
84
+ ```js
85
+ Game.cli.setControllerLevel('67c8...df8', 8)
86
+ ```
87
+
88
+ ### “秒建”房间内建筑工地(接近完成)
89
+
90
+ 把指定房间内所有工地设置为:`progress = progressTotal - 1`
91
+
92
+ ```js
93
+ Game.cli.finishConstructionSites(['W1N9'])
94
+ ```
95
+
96
+ ## 安全说明
97
+
98
+ 执行服务端 CLI JS 权限很大:
99
+
100
+ - CLI 沙箱可访问服务端内部对象,例如 `storage.*`、`system.*`。
101
+ - 本 mod **默认拒绝**。不建议在公开服务器对所有玩家开放,除非你非常清楚风险并做好额外限制。
102
+
103
+ 权限差异:
104
+
105
+ - **普通用户**:会被限制 CLI 沙箱能力:
106
+ - `storage` 会被替换成受限包装(主要限制到自己名下的房间/对象)
107
+ - `system/map/bots/strongholds` 会被移除
108
+ - **超管**:可使用完整 CLI 沙箱(可选开启 `users.code` 仅自身可读写的隐私保护)
109
+
110
+ ## 常见问题
111
+
112
+ - **`Game.cli` 不存在**:
113
+ - 确认已在 `mods.json` 启用并重启私服。
114
+ - 确认环境可用 `isolated-vm`(Screeps 玩家沙箱依赖它)。
115
+ - **提示 `[cli] denied: not allowed user`**:
116
+ - 把你的 userId / 用户名加入 `SETTINGS.normalUserIds/normalUsernames` 或 `superAdmin*`。
117
+ - **输出缺失/被截断**:
118
+ - 调整 `maxOutputLines`、`evalTimeoutMs`、`promiseTimeoutMs`。
119
+
120
+ ## License
121
+
122
+ MIT
package/index.js ADDED
@@ -0,0 +1,698 @@
1
+ /**
2
+ * Allow players to execute (some) server CLI-only commands from the in-game console.
3
+ *
4
+ * Why this is tricky:
5
+ * - Player runtime uses `isolated-vm`, you can't inject a normal JS function via sandbox.set().
6
+ * - But `playerSandbox` exposes the underlying isolate/context, so we can inject an
7
+ * `isolated-vm` Reference and call it from user code.
8
+ *
9
+ * SECURITY WARNING:
10
+ * Running arbitrary CLI JS gives full access to the server database (`storage.*`) and
11
+ * system controls (`system.pauseSimulation()`, etc). This mod is **DENY BY DEFAULT**.
12
+ * Add your admin user ids/usernames to SETTINGS below before using.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const path = require('path');
18
+ const EventEmitter = require('events').EventEmitter;
19
+ const vm = require('vm');
20
+ const util = require('util');
21
+
22
+ // ---- Settings (edit me) ----------------------------------------------------
23
+
24
+ const SETTINGS = {
25
+ /**
26
+ * If true, EVERY real player is treated as a "normal" allowed user (see `resolveRole()`),
27
+ * i.e. they can call `Game.cli.exec()` without being in `normalUserIds/normalUsernames`.
28
+ *
29
+ * IMPORTANT: This does NOT grant "super admin" capabilities. Normal users get a restricted
30
+ * CLI sandbox (see `getCliSandbox()`):
31
+ * - `storage` is replaced with a restricted wrapper (mostly limited to their own rooms/objects)
32
+ * - `system/map/bots/strongholds` are removed
33
+ *
34
+ * Keep it false unless this server is a single-player/dev sandbox.
35
+ */
36
+ allowAllUsers: false,
37
+
38
+ /**
39
+ * Normal permission allow-list (used only when allowAllUsers=false).
40
+ * Normal users can ONLY access:
41
+ * - storage.db.rooms (restricted to rooms they own)
42
+ * - storage.db.objects / storage.db['rooms.objects'] (restricted to {user: self})
43
+ * - storage.db.creeps (view over rooms.objects with {type:'creep', user:self})
44
+ */
45
+ normalUserIds: [],
46
+ normalUsernames: [],
47
+
48
+ /**
49
+ * Super admin allow-list.
50
+ * Super admins can execute any CLI JS and access all CLI sandbox objects.
51
+ */
52
+ superAdminUserIds: [],
53
+ superAdminUsernames: [],
54
+
55
+ /**
56
+ * Privacy guard:
57
+ * Even for super admins, do NOT allow reading/modifying other users' code in `users.code`.
58
+ * (Super admins can still access other tables unless you restrict them elsewhere.)
59
+ */
60
+ superAdminUsersCodeSelfOnly: true,
61
+
62
+ /**
63
+ * Optional prefix allow-list for the CLI JS string.
64
+ * If non-empty, only commands starting with one of these prefixes will be allowed.
65
+ *
66
+ * Examples:
67
+ * - ['system.', 'map.'] -> allow only system.* and map.* helpers
68
+ * - ['help(', 'print('] -> allow only help()/print()
69
+ * - [] -> allow any JS (still subject to user allow-list)
70
+ */
71
+ allowedCodePrefixes: [],
72
+
73
+ /**
74
+ * Basic abuse limits.
75
+ */
76
+ maxCodeLength: 2000,
77
+ maxOutputLines: 60,
78
+ evalTimeoutMs: 2000, // Node vm timeout for sync execution
79
+ promiseTimeoutMs: 5000, // waiting for returned promise/thenable
80
+ };
81
+
82
+ // ---------------------------------------------------------------------------
83
+
84
+ // In Steam-distributed Screeps server, dependencies may live under `package/node_modules/`
85
+ // instead of the normal top-level `node_modules/`. This helper makes the mod loadable in both.
86
+ function req(name) {
87
+ try {
88
+ // eslint-disable-next-line import/no-dynamic-require, global-require
89
+ return require(name);
90
+ } catch (e) {
91
+ // eslint-disable-next-line import/no-dynamic-require, global-require
92
+ return require(path.resolve(__dirname, '..', 'package', 'node_modules', name));
93
+ }
94
+ }
95
+
96
+ const common = req('@screeps/common');
97
+ const q = req('q');
98
+ let ivm; // lazy-loaded (native module)
99
+ let ObjectId; // optional, for Mongo-backed servers
100
+
101
+ function getIvm() {
102
+ if (ivm) return ivm;
103
+ try {
104
+ ivm = req('isolated-vm');
105
+ return ivm;
106
+ } catch (e) {
107
+ console.error('[execute-cli mod] cannot load isolated-vm:', e && (e.stack || e));
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function getObjectId() {
113
+ if (ObjectId) return ObjectId;
114
+ // Optional dependency: in some servers (MongoDB), ObjectId exists.
115
+ try {
116
+ const mongodb = req('mongodb');
117
+ ObjectId = mongodb && (mongodb.ObjectId || (mongodb.BSON && mongodb.BSON.ObjectId));
118
+ return ObjectId;
119
+ } catch (e) {}
120
+ try {
121
+ const bson = req('bson');
122
+ ObjectId = bson && bson.ObjectId;
123
+ return ObjectId;
124
+ } catch (e) {}
125
+ return null;
126
+ }
127
+
128
+ function normalizeList(list) {
129
+ return (Array.isArray(list) ? list : [])
130
+ .map(i => (i === null || i === undefined) ? '' : String(i))
131
+ .map(i => i.trim())
132
+ .filter(Boolean);
133
+ }
134
+
135
+ function isNpcUserId(userId) {
136
+ // In this codebase, "2" and "3" are used for NPCs in driver.sendConsoleMessages
137
+ return userId === '2' || userId === '3';
138
+ }
139
+
140
+ function normalizeIdSet(list) {
141
+ return new Set(normalizeList(list));
142
+ }
143
+
144
+ function normalizeNameSet(list) {
145
+ return new Set(normalizeList(list).map(i => i.toLowerCase()));
146
+ }
147
+
148
+ function getEffectiveSuperAdminSets() {
149
+ return {
150
+ ids: normalizeIdSet(SETTINGS.superAdminUserIds),
151
+ names: normalizeNameSet(SETTINGS.superAdminUsernames),
152
+ };
153
+ }
154
+
155
+ function getEffectiveNormalSets() {
156
+ return {
157
+ ids: normalizeIdSet(SETTINGS.normalUserIds),
158
+ names: normalizeNameSet(SETTINGS.normalUsernames),
159
+ };
160
+ }
161
+
162
+ function isUpdateTryingToChangeUser(updateDoc, userId) {
163
+ if (!updateDoc || typeof updateDoc !== 'object') return false;
164
+ if (Object.prototype.hasOwnProperty.call(updateDoc, 'user')) {
165
+ return String(updateDoc.user) !== String(userId);
166
+ }
167
+ const ops = ['$set', '$merge', '$unset'];
168
+ for (const op of ops) {
169
+ const part = updateDoc[op];
170
+ if (!part || typeof part !== 'object') continue;
171
+ if (Object.prototype.hasOwnProperty.call(part, 'user')) {
172
+ if (op === '$unset') return true;
173
+ return String(part.user) !== String(userId);
174
+ }
175
+ }
176
+ return false;
177
+ }
178
+
179
+ function makeRestrictedDbForUser(userId) {
180
+ userId = String(userId);
181
+ const db = common.storage.db;
182
+
183
+ const OID = getObjectId();
184
+ const looksLikeObjectId = /^[a-f\d]{24}$/i.test(userId);
185
+ const userMatch = (() => {
186
+ if (OID && looksLikeObjectId) {
187
+ try {
188
+ const oid = new OID(userId);
189
+ return {$or: [{user: userId}, {user: oid}]};
190
+ } catch (e) {}
191
+ }
192
+ return {user: userId};
193
+ })();
194
+
195
+ let ownedRoomsPromise;
196
+ function ownedRoomIds() {
197
+ if (!ownedRoomsPromise) {
198
+ // Prefer simple queries for compatibility with different storage backends.
199
+ ownedRoomsPromise = q.when()
200
+ .then(() => db['rooms.objects'].find({type: 'controller', user: userId}))
201
+ .then((controllers) => {
202
+ if (controllers && controllers.length) return controllers;
203
+ if (OID && looksLikeObjectId) {
204
+ try {
205
+ return db['rooms.objects'].find({type: 'controller', user: new OID(userId)});
206
+ } catch (e) {}
207
+ }
208
+ return controllers || [];
209
+ })
210
+ .then((controllers) => (controllers || []).map(i => i.room).filter(Boolean));
211
+ }
212
+ return ownedRoomsPromise;
213
+ }
214
+
215
+ function andQuery(q1, q2) {
216
+ if (!q1) return q2;
217
+ if (!q2) return q1;
218
+ return {$and: [q1, q2]};
219
+ }
220
+
221
+ function forbid(msg) {
222
+ return q.reject(new Error(msg));
223
+ }
224
+
225
+ function wrapRooms() {
226
+ const col = db.rooms;
227
+ return {
228
+ find(query, opts) {
229
+ return ownedRoomIds().then(roomIds => col.find(andQuery(query, {_id: {$in: roomIds}}), opts));
230
+ },
231
+ findOne(query, opts) {
232
+ return ownedRoomIds().then(roomIds => col.findOne(andQuery(query, {_id: {$in: roomIds}}), opts));
233
+ },
234
+ count(query) {
235
+ return ownedRoomIds().then(roomIds => col.count(andQuery(query, {_id: {$in: roomIds}})));
236
+ },
237
+ findEx(query, opts) {
238
+ return ownedRoomIds().then(roomIds => col.findEx(andQuery(query, {_id: {$in: roomIds}}), opts));
239
+ },
240
+ update(query, updateDoc, params) {
241
+ return ownedRoomIds().then(roomIds => col.update(andQuery(query, {_id: {$in: roomIds}}), updateDoc, params));
242
+ },
243
+ insert() { return forbid('rooms.insert is not allowed for normal users'); },
244
+ removeWhere() { return forbid('rooms.removeWhere is not allowed for normal users'); },
245
+ clear() { return forbid('rooms.clear is not allowed for normal users'); },
246
+ by() { return forbid('rooms.by is not allowed for normal users'); },
247
+ ensureIndex() { return forbid('rooms.ensureIndex is not allowed for normal users'); },
248
+ bulk() { return forbid('rooms.bulk is not allowed for normal users'); },
249
+ };
250
+ }
251
+
252
+ function wrapRoomObjects(extraQuery, name) {
253
+ const col = db['rooms.objects'];
254
+ const base = andQuery(extraQuery, userMatch);
255
+
256
+ function mapInsert(obj) {
257
+ const out = Object.assign({}, obj);
258
+ if (out.user && String(out.user) !== userId) {
259
+ throw new Error(`${name}.insert denied: user mismatch`);
260
+ }
261
+ out.user = userId;
262
+ return out;
263
+ }
264
+
265
+ return {
266
+ find(query, opts) { return col.find(andQuery(query, base), opts); },
267
+ findOne(query, opts) { return col.findOne(andQuery(query, base), opts); },
268
+ count(query) { return col.count(andQuery(query, base)); },
269
+ findEx(query, opts) { return col.findEx(andQuery(query, base), opts); },
270
+ update(query, updateDoc, params) {
271
+ if (isUpdateTryingToChangeUser(updateDoc, userId)) {
272
+ return forbid(`${name}.update denied: cannot change user field`);
273
+ }
274
+ // Mongo-backed servers may store `user` as ObjectId. Complex `$and/$or` queries can behave
275
+ // differently across backends. To keep "normal" permissions usable, do:
276
+ // 1) Require `_id` update
277
+ // 2) Verify ownership by reading the object
278
+ // 3) Update by `_id` only
279
+ if (!query || typeof query !== 'object' || typeof query._id === 'undefined') {
280
+ return forbid(`${name}.update denied: normal users must update by _id`);
281
+ }
282
+ return q.when(col.findOne({_id: query._id}))
283
+ .then((obj) => {
284
+ if (!obj) {
285
+ return {n: 0, nModified: 0, ok: 1};
286
+ }
287
+ if (String(obj.user) !== String(userId)) {
288
+ return forbid(`${name}.update denied: not owner`);
289
+ }
290
+ if (extraQuery && extraQuery.type && obj.type !== extraQuery.type) {
291
+ return forbid(`${name}.update denied: wrong type`);
292
+ }
293
+ return col.update({_id: query._id}, updateDoc, params);
294
+ });
295
+ },
296
+ removeWhere(query) { return col.removeWhere(andQuery(query, base)); },
297
+ insert(objOrArr) {
298
+ try {
299
+ const arr = Array.isArray(objOrArr) ? objOrArr : [objOrArr];
300
+ const mapped = arr.map(mapInsert);
301
+ return col.insert(Array.isArray(objOrArr) ? mapped : mapped[0]);
302
+ } catch (e) {
303
+ return q.reject(e);
304
+ }
305
+ },
306
+ clear() { return forbid(`${name}.clear is not allowed for normal users`); },
307
+ by() { return forbid(`${name}.by is not allowed for normal users`); },
308
+ ensureIndex() { return forbid(`${name}.ensureIndex is not allowed for normal users`); },
309
+ bulk() { return forbid(`${name}.bulk is not allowed for normal users`); },
310
+ };
311
+ }
312
+
313
+ const objects = wrapRoomObjects(null, 'objects');
314
+ const creeps = wrapRoomObjects({type: 'creep'}, 'creeps');
315
+
316
+ return Object.create(null, {
317
+ rooms: {value: wrapRooms(), enumerable: true},
318
+ objects: {value: objects, enumerable: true},
319
+ creeps: {value: creeps, enumerable: true},
320
+ 'rooms.objects': {value: objects, enumerable: true},
321
+ });
322
+ }
323
+
324
+ function makeRestrictedStorageForUser(userId) {
325
+ return {db: makeRestrictedDbForUser(userId)};
326
+ }
327
+
328
+ function makeUsersCodeSelfOnlyWrapper(usersCodeCollection, userId) {
329
+ userId = String(userId);
330
+ function andQuery(q1, q2) {
331
+ if (!q1) return q2;
332
+ if (!q2) return q1;
333
+ return {$and: [q1, q2]};
334
+ }
335
+ function forbid(msg) {
336
+ return q.reject(new Error(msg));
337
+ }
338
+ function mapInsert(obj) {
339
+ const out = Object.assign({}, obj);
340
+ if (out.user && String(out.user) !== userId) {
341
+ throw new Error(`users.code.insert denied: cannot set user != self`);
342
+ }
343
+ out.user = userId;
344
+ return out;
345
+ }
346
+ return {
347
+ find(query, opts) { return usersCodeCollection.find(andQuery(query, {user: userId}), opts); },
348
+ findOne(query, opts) { return usersCodeCollection.findOne(andQuery(query, {user: userId}), opts); },
349
+ count(query) { return usersCodeCollection.count(andQuery(query, {user: userId})); },
350
+ findEx(query, opts) { return usersCodeCollection.findEx(andQuery(query, {user: userId}), opts); },
351
+ update(query, updateDoc, params) {
352
+ if (isUpdateTryingToChangeUser(updateDoc, userId)) {
353
+ return forbid('users.code.update denied: cannot change user field');
354
+ }
355
+ return usersCodeCollection.update(andQuery(query, {user: userId}), updateDoc, params);
356
+ },
357
+ removeWhere(query) { return usersCodeCollection.removeWhere(andQuery(query, {user: userId})); },
358
+ insert(objOrArr) {
359
+ try {
360
+ const arr = Array.isArray(objOrArr) ? objOrArr : [objOrArr];
361
+ const mapped = arr.map(mapInsert);
362
+ return usersCodeCollection.insert(Array.isArray(objOrArr) ? mapped : mapped[0]);
363
+ } catch (e) {
364
+ return q.reject(e);
365
+ }
366
+ },
367
+ clear() { return forbid('users.code.clear is not allowed'); },
368
+ by() { return forbid('users.code.by is not allowed'); },
369
+ ensureIndex() { return forbid('users.code.ensureIndex is not allowed'); },
370
+ bulk() { return forbid('users.code.bulk is not allowed'); },
371
+ };
372
+ }
373
+
374
+ function toConsoleLines(value) {
375
+ if (value === undefined) return [];
376
+ if (typeof value === 'string') return [value];
377
+ return [util.inspect(value, {depth: 3, maxArrayLength: 50})];
378
+ }
379
+
380
+ function sendConsole(config, userId, lines, {asResult = false} = {}) {
381
+ if (!config || !config.engine || !config.engine.driver || !lines || !lines.length) return;
382
+ const payload = asResult ? {log: [], results: lines} : {log: lines, results: []};
383
+ try {
384
+ config.engine.driver.sendConsoleMessages(userId, payload);
385
+ } catch (e) {
386
+ // Avoid crashing runner loop because of console output issues.
387
+ console.error('[execute-cli mod] sendConsoleMessages failed:', e && (e.stack || e));
388
+ }
389
+ }
390
+
391
+ function getCliSandbox(config, outputCallback, opts) {
392
+ opts = opts || {};
393
+ // Ensure config.cli exists even in non-backend processes (runner/processor).
394
+ if (!config.cli) {
395
+ config.cli = new EventEmitter();
396
+ }
397
+
398
+ // Patch config.cli.createSandbox (backend normally does this in @screeps/backend startup).
399
+ // We require it lazily to avoid side effects if this mod is loaded in processes that don't need it.
400
+ req('@screeps/backend/lib/cli/sandbox');
401
+
402
+ const sandbox = common.configManager.config.cli.createSandbox((data, isResult) => {
403
+ outputCallback(data, isResult);
404
+ });
405
+
406
+ if (opts.role === 'normal') {
407
+ sandbox.storage = makeRestrictedStorageForUser(opts.userId);
408
+ sandbox.system = undefined;
409
+ sandbox.map = undefined;
410
+ sandbox.bots = undefined;
411
+ sandbox.strongholds = undefined;
412
+ }
413
+ else if (opts.role === 'super' && SETTINGS.superAdminUsersCodeSelfOnly) {
414
+ // Keep full CLI capabilities, but protect user code privacy.
415
+ // IMPORTANT: do NOT mutate global `common.storage` (cli sandbox uses it by reference).
416
+ // Otherwise, after a super admin runs CLI once, the whole process would be unable to
417
+ // load other users' code (e.g. "main not found").
418
+ if (sandbox.storage && sandbox.storage.db && sandbox.storage.db['users.code']) {
419
+ const storageClone = Object.assign({}, sandbox.storage);
420
+ storageClone.db = Object.assign({}, sandbox.storage.db);
421
+ storageClone.db['users.code'] = makeUsersCodeSelfOnlyWrapper(sandbox.storage.db['users.code'], opts.userId);
422
+ sandbox.storage = storageClone;
423
+ }
424
+ }
425
+
426
+ // Fresh context per call (safer than keeping a persistent one).
427
+ const context = vm.createContext(sandbox);
428
+ return {sandbox, context};
429
+ }
430
+
431
+ function checkAllowed(config, userId, code) {
432
+ if (!userId || isNpcUserId(userId)) {
433
+ return {ok: false, reason: 'NPC user is not allowed'};
434
+ }
435
+
436
+ if (typeof code !== 'string' || !code.trim()) {
437
+ return {ok: false, reason: 'empty code'};
438
+ }
439
+ if (code.length > SETTINGS.maxCodeLength) {
440
+ return {ok: false, reason: `code too long (>${SETTINGS.maxCodeLength})`};
441
+ }
442
+
443
+ const prefixes = normalizeList(SETTINGS.allowedCodePrefixes);
444
+ if (prefixes.length) {
445
+ const ok = prefixes.some(p => code.trim().startsWith(p));
446
+ if (!ok) {
447
+ return {ok: false, reason: `code prefix not allowed (allowed: ${prefixes.join(', ')})`};
448
+ }
449
+ }
450
+
451
+ return {ok: true};
452
+ }
453
+
454
+ async function resolveRole(userId) {
455
+ userId = String(userId);
456
+ const superSets = getEffectiveSuperAdminSets();
457
+ const normalSets = getEffectiveNormalSets();
458
+
459
+ if (superSets.ids.has(userId)) return 'super';
460
+ if (SETTINGS.allowAllUsers) return 'normal';
461
+ if (normalSets.ids.has(userId)) return 'normal';
462
+
463
+ const needNameLookup = superSets.names.size > 0 || normalSets.names.size > 0;
464
+ if (!needNameLookup) return null;
465
+
466
+ const user = await common.storage.db.users.findOne({_id: userId});
467
+ const usernameLower = user && user.username ? String(user.username).toLowerCase() : '';
468
+ if (usernameLower && superSets.names.has(usernameLower)) return 'super';
469
+ if (usernameLower && normalSets.names.has(usernameLower)) return 'normal';
470
+ return null;
471
+ }
472
+
473
+ function withTimeout(promise, ms, label) {
474
+ // Use `q` here since some older Node versions in Screeps runtime don't support Promise.prototype.finally.
475
+ if (!promise || typeof promise.then !== 'function') {
476
+ return q.when(promise);
477
+ }
478
+ return q.race([
479
+ q.when(promise),
480
+ q.delay(ms).then(() => ({__timeout: true, label})),
481
+ ]);
482
+ }
483
+
484
+ async function runCliForUser(config, userId, code) {
485
+ const check = checkAllowed(config, userId, code);
486
+ if (!check.ok) {
487
+ sendConsole(config, userId, [`[cli] denied: ${check.reason}`], {asResult: true});
488
+ return;
489
+ }
490
+
491
+ const role = await resolveRole(userId);
492
+ if (!role) {
493
+ sendConsole(config, userId, [`[cli] denied: not allowed user`], {asResult: true});
494
+ return;
495
+ }
496
+
497
+ let outLines = [];
498
+ const pushLine = (line, isResult) => {
499
+ if (outLines.length >= SETTINGS.maxOutputLines) return;
500
+ outLines.push(String(line));
501
+ // Flush progressively to the player's console (feels more like real CLI).
502
+ sendConsole(config, userId, [String(line)], {asResult: !!isResult});
503
+ };
504
+
505
+ try {
506
+ const {context} = getCliSandbox(config, (data, isResult) => pushLine(data, isResult), {role, userId});
507
+
508
+ // Execute. `timeout` here prevents sync infinite loops.
509
+ const result = vm.runInContext(code, context, {timeout: SETTINGS.evalTimeoutMs});
510
+
511
+ // If returned a promise/thenable, wait a bit and show its result (or timeout).
512
+ if (result && typeof result.then === 'function') {
513
+ const awaited = await withTimeout(result, SETTINGS.promiseTimeoutMs, 'promise');
514
+ if (awaited && awaited.__timeout) {
515
+ pushLine(`[cli] promise not resolved within ${SETTINGS.promiseTimeoutMs}ms`, true);
516
+ } else if (awaited !== undefined) {
517
+ toConsoleLines(awaited).forEach(l => pushLine(l, true));
518
+ }
519
+ } else if (result !== undefined) {
520
+ toConsoleLines(result).forEach(l => pushLine(l, true));
521
+ }
522
+ }
523
+ catch (e) {
524
+ pushLine(`[cli] Error: ${(e && (e.stack || e))}`, true);
525
+ }
526
+ finally {
527
+ if (outLines.length >= SETTINGS.maxOutputLines) {
528
+ sendConsole(config, userId, [`[cli] output truncated (>${SETTINGS.maxOutputLines} lines)`], {asResult: true});
529
+ }
530
+ }
531
+ }
532
+
533
+ module.exports = function(config) {
534
+ if (!config || !config.engine) {
535
+ return;
536
+ }
537
+
538
+ const _ivm = getIvm();
539
+ if (!_ivm) {
540
+ // If isolated-vm cannot be loaded in this process, we can't inject host callbacks.
541
+ return;
542
+ }
543
+
544
+ // Ensure config.cli exists early, so other mods loaded AFTER this one can attach `cliSandbox` listeners
545
+ // even in runner processes (optional, but helpful).
546
+ if (!config.cli) {
547
+ config.cli = new EventEmitter();
548
+ }
549
+
550
+ config.engine.on('playerSandbox', function(sandbox, userId) {
551
+ // Inject a host callback into player's isolate.
552
+ const ctx = sandbox.getContext();
553
+ const refName = '__playerCliExec';
554
+
555
+ // A synchronous "enqueue" wrapper, to allow calling it from user console without async plumbing.
556
+ // The real execution happens async and writes outputs to the in-game console.
557
+ const enqueueFn = function(code) {
558
+ try {
559
+ // Do not block the isolate; run on next tick in Node.
560
+ setImmediate(() => {
561
+ runCliForUser(config, String(userId), String(code));
562
+ });
563
+ } catch (e) {
564
+ console.error('[execute-cli mod] enqueue failed:', e && (e.stack || e));
565
+ }
566
+ return '[cli] queued';
567
+ };
568
+
569
+ try {
570
+ ctx.global.setIgnored(refName, new _ivm.Reference(enqueueFn));
571
+
572
+ // Attach a nice API for players.
573
+ // Usage in in-game console:
574
+ // Game.cli.exec('help()')
575
+ // Game.cli.exec('system.getTickDuration()')
576
+ sandbox.run(`
577
+ (function() {
578
+ if(typeof Game !== 'object' || !Game) return;
579
+ Game.cli = Game.cli || Object.create(null);
580
+ Game.cli.exec = function(code) {
581
+ return global.${refName}.applySync(undefined, [String(code)], {
582
+ arguments: { copy: true },
583
+ result: { copy: true }
584
+ });
585
+ };
586
+ Game.cli._normalizeId = function(target) {
587
+ if(!target) return target;
588
+ if(typeof target === 'string') return target;
589
+ if(typeof target === 'object') {
590
+ if(target._id) return String(target._id);
591
+ if(target.id) return String(target.id);
592
+ }
593
+ return String(target);
594
+ };
595
+
596
+ // Set store field for a rooms.objects item (by _id / id / object).
597
+ // Example:
598
+ // Game.cli.setStore('679f...67f3', {energy: 1000})
599
+ Game.cli.setStore = function(target, store) {
600
+ var id = Game.cli._normalizeId(target);
601
+ var storeJson = JSON.stringify(store || {});
602
+ return Game.cli.exec(
603
+ "storage.db['rooms.objects'].update({ _id: " + JSON.stringify(id) + " }, { $set: { store: " + storeJson + " } })"
604
+ );
605
+ };
606
+
607
+ // Preset: huge store (useful for local testing / cheating).
608
+ // This just calls setStore() with a predefined payload.
609
+ Game.cli.setStoreHuge = function(target) {
610
+ return Game.cli.setStore(target, {
611
+ energy: 5000000,
612
+ power: 100000,
613
+ ops: 100000,
614
+ XUHO2: 100000,
615
+ XUH2O: 100000,
616
+ XKH2O: 100000,
617
+ XKHO2: 100000,
618
+ XLH2O: 100000,
619
+ XLHO2: 100000,
620
+ XZH2O: 100000,
621
+ XZHO2: 100000,
622
+ XGH2O: 100000,
623
+ XGHO2: 100000,
624
+ X: 100000,
625
+ O: 100000,
626
+ H: 100000,
627
+ Z: 100000,
628
+ L: 100000,
629
+ K: 100000,
630
+ U: 100000
631
+ });
632
+ };
633
+
634
+ // Set controller level by rooms.objects _id / object.
635
+ // Example:
636
+ // Game.cli.setControllerLevel('67c8...df8', 8)
637
+ Game.cli.setControllerLevel = function(target, level) {
638
+ var id = Game.cli._normalizeId(target);
639
+ var lvl = parseInt(level, 10);
640
+ if(isNaN(lvl)) lvl = 0;
641
+ if(lvl < 0) lvl = 0;
642
+ if(lvl > 8) lvl = 8;
643
+ return Game.cli.exec(
644
+ "storage.db['rooms.objects'].update({ _id: " + JSON.stringify(id) + " }, { $set: { level: " + JSON.stringify(lvl) + " } })"
645
+ );
646
+ };
647
+
648
+ // Set all construction sites in given rooms to "almost done":
649
+ // progress = progressTotal - 1
650
+ // Example:
651
+ // Game.cli.finishConstructionSites(['W1N9'])
652
+ Game.cli.finishConstructionSites = function(rooms) {
653
+ if(!rooms) rooms = [];
654
+ if(typeof rooms === 'string') rooms = [rooms];
655
+ var roomsJson = JSON.stringify(rooms);
656
+ var code =
657
+ "(function(){" +
658
+ "return storage.db['rooms.objects'].find({ type: 'constructionSite', room: { $in: " + roomsJson + " } })" +
659
+ ".then(function(list){" +
660
+ " var p = storage.db['rooms.objects'].count({ _id: { $in: [] } });" + // an already-resolved thenable
661
+ " (list||[]).forEach(function(cs){" +
662
+ " p = p.then(function(){" +
663
+ " var total = cs && cs.progressTotal;" +
664
+ " if(typeof total === 'number') {" +
665
+ " return storage.db['rooms.objects'].update({ _id: cs._id }, { $set: { progress: total - 1 } });" +
666
+ " }" +
667
+ " return storage.db['rooms.objects'].findOne({ _id: cs._id }).then(function(full){" +
668
+ " var t = full && full.progressTotal;" +
669
+ " if(typeof t !== 'number') t = 0;" +
670
+ " return storage.db['rooms.objects'].update({ _id: cs._id }, { $set: { progress: t - 1 } });" +
671
+ " });" +
672
+ " });" +
673
+ " });" +
674
+ " return p.then(function(){ return 'OK'; });" +
675
+ "});" +
676
+ "})();";
677
+ return Game.cli.exec(code);
678
+ };
679
+
680
+ Game.cli._help = [
681
+ "Game.cli.exec(code): execute server CLI JS. Output will appear in your console.",
682
+ "Game.cli.setStore(target, store): update rooms.objects.store by id/_id/object.",
683
+ "Game.cli.setStoreHuge(target): set a large predefined store payload.",
684
+ "Game.cli.setControllerLevel(target, level): update controller level by rooms.objects _id/object.",
685
+ "Game.cli.finishConstructionSites(rooms): set construction sites progress to progressTotal-1 for given rooms."
686
+ ].join("\\n");
687
+
688
+ Game.cli.help = function() {
689
+ return Game.cli._help || "";
690
+ };
691
+ })();
692
+ `);
693
+ }
694
+ catch (e) {
695
+ console.error('[execute-cli mod] playerSandbox injection failed:', e && (e.stack || e));
696
+ }
697
+ });
698
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "screepsmod-exec-cli-in-console",
3
+ "version": "1.0.0",
4
+ "description": "Allow players to execute (some) server CLI-only commands from the in-game console.",
5
+ "main": "index.js",
6
+ "scripts": {},
7
+ "keywords": [
8
+ "screeps",
9
+ "mod",
10
+ "execute",
11
+ "cli",
12
+ "console"
13
+ ],
14
+ "screeps_mod": true,
15
+ "files": [
16
+ "index.js",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "author": {
21
+ "name": "lovezhangchuangxin",
22
+ "email": "2911331070@qq.com",
23
+ "url": "https://github.com/lovezhangchuangxin"
24
+ },
25
+ "license": "MIT",
26
+ "packageManager": "pnpm@10.22.0"
27
+ }