fiverunner 1.3.0 → 1.4.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/README.md +10 -0
- package/dist/fiverunner.js +369 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,6 +93,16 @@ npx fiverunner audit
|
|
|
93
93
|
|
|
94
94
|
A player logs in, triggers each event that clients may trigger with the arguments that cheaters give, and the audit tells what any client gains, such as money, items or a job, what it changes in the database, and what breaks. It exits with 1 when any client gains something, so CI can fail on it. [docs/audit.md](docs/audit.md) tells how it works and what it cannot see.
|
|
95
95
|
|
|
96
|
+
## AI assistants
|
|
97
|
+
|
|
98
|
+
Let Claude Code, Cursor or VS Code run your resources as they write them, so they check what your scripts do in place of guessing:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
claude mcp add fiverunner -- npx -y fiverunner mcp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
On Windows, give `cmd /c npx` in place of `npx`. `fiverunner mcp` serves the Model Context Protocol. Its tools describe what the server started, run your tests, run Lua against a fresh server of your project, with players that join and trigger events, and audit its events. [docs/mcp.md](docs/mcp.md) tells how to set it up in each assistant.
|
|
105
|
+
|
|
96
106
|
## Developing resources
|
|
97
107
|
|
|
98
108
|
Run your project in your browser:
|
package/dist/fiverunner.js
CHANGED
|
@@ -6,7 +6,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
7
|
var package_default = {
|
|
8
8
|
name: "fiverunner",
|
|
9
|
-
version: "1.
|
|
9
|
+
version: "1.4.0",
|
|
10
10
|
description: "Run FiveM resources (Lua, JavaScript and NUI) in your browser and in CI. No GTA V, no server, no restarts.",
|
|
11
11
|
keywords: [
|
|
12
12
|
"fivem",
|
|
@@ -102757,7 +102757,7 @@ function text$1(value) {
|
|
|
102757
102757
|
var audit_default = "-- FiveRunner's audit, which `fiverunner audit` runs as a test of the project:\n-- it triggers each event of the server that clients may trigger, from a\n-- player, with the arguments that cheaters give, and tells what each changed\n-- of what the player has, of the database and of the world, as lines of JSON.\n\nlocal MARK = 'fiverunner:audit '\n\n--- Tells `fiverunner audit` of something, as a line of JSON.\nlocal function tell(record)\n print(MARK .. json.encode(record))\nend\n\nlocal function started(name)\n return GetResourceState(name) == 'started'\nend\n\n-- The framework that logs players in, and the inventory that holds their items.\nlocal framework = started('qbx_core') and 'qbx' or started('qb-core') and 'qb' or started('es_extended') and 'esx' or nil\nlocal oxInventory = started('ox_inventory')\n\nlocal CHARINFO = { firstname = 'Audit', lastname = 'Player', birthdate = '1990-01-01', gender = 0, nationality = 'Romania' }\n\nlocal core\n\n--- The shared object of QBCore or ESX, which exports copy whole, so once.\nlocal function coreOf()\n if core == nil then\n if framework == 'qb' then core = exports['qb-core']:GetCoreObject() end\n if framework == 'esx' then core = exports.es_extended:getSharedObject() end\n end\n return core\nend\n\n--- The object of the framework for a player, once it logged in.\nlocal function frameworkPlayer(id)\n local ok, found = pcall(function()\n if framework == 'qbx' then return exports.qbx_core:GetPlayer(id) end\n if framework == 'qb' then return coreOf().Functions.GetPlayer(id) end\n if framework == 'esx' then return coreOf().GetPlayerFromId(id) end\n end)\n return ok and found or nil\nend\n\n--- Whether the client of `player` sent the page of `resource` a message of `action`.\nlocal function sent(player, resource, action)\n for _, message in ipairs(player:nuiMessages(resource)) do\n if message.action == action then return true end\n end\n return false\nend\n\nlocal joined = 0\n\n--- Connects a player and logs it in with a new character, as the framework of the server has players do.\nlocal function join()\n joined = joined + 1\n local player, why = fiverunner.connect({ name = 'audit' .. joined })\n if not player then return nil, why end\n pcall(function()\n if framework == 'qbx' then\n exports.qbx_core:Login(player.id, nil, { charinfo = CHARINFO })\n elseif framework == 'qb' and started('qb-multicharacter') then\n fiverunner.waitFor(function() return sent(player, 'qb-multicharacter', 'ui') end, 10000)\n player:nuiCallback('qb-multicharacter', 'createNewCharacter', {\n firstname = 'Audit', lastname = 'Player', nationality = 'Romania', birthdate = '1990-01-01', gender = 'Male', cid = 1,\n })\n elseif framework == 'qb' then\n coreOf().Player.Login(player.id, false, { charinfo = CHARINFO })\n end\n end)\n if framework then\n pcall(fiverunner.waitFor, function() return frameworkPlayer(player.id) end, 10000)\n end\n return player\nend\n\n--- Whether `player` is still on the server, and still logged in to its framework.\nlocal function present(player)\n if GetPlayerName(player.id) == nil then return false end\n return framework == nil or frameworkPlayer(player.id) ~= nil\nend\n\n--- What `player` has: money by account, items by name, job, gang, group, and whether it may run every command.\nlocal function holdings(player)\n local held = { money = {}, items = {}, admin = IsPlayerAceAllowed(player.id, 'command') }\n local found = frameworkPlayer(player.id)\n pcall(function()\n if found and framework == 'esx' then\n for account, money in pairs(found.getAccounts(true)) do held.money[account] = money end\n if not oxInventory then\n for name, count in pairs(found.getInventory(true)) do held.items[name] = count end\n end\n local job = found.getJob()\n held.job = ('%s %s'):format(job.name, job.grade)\n held.group = found.getGroup()\n elseif found then\n local data = found.PlayerData\n for account, money in pairs(data.money or {}) do held.money[account] = money end\n if not oxInventory then\n for _, item in pairs(data.items or {}) do\n held.items[item.name] = (held.items[item.name] or 0) + (item.amount or item.count or 0)\n end\n end\n if data.job then held.job = ('%s %s'):format(data.job.name, data.job.grade and data.job.grade.level) end\n if data.gang then held.gang = ('%s %s'):format(data.gang.name, data.gang.grade and data.gang.grade.level) end\n end\n end)\n if oxInventory then\n pcall(function()\n for _, item in pairs(exports.ox_inventory:GetInventoryItems(player.id) or {}) do\n held.items[item.name] = (held.items[item.name] or 0) + item.count\n end\n end)\n end\n return held\nend\n\nlocal function byText(a, b)\n return a.text < b.text\nend\n\n--- What `player` gained from `before` to `after`, as { kind, text }, with\n--- kind money, item or field, and the money that it lost, as words.\nlocal function diff(before, after)\n local gained, lost = {}, {}\n for account, money in pairs(after.money) do\n local was = before.money[account] or 0\n if type(money) == 'number' and type(was) == 'number' then\n if money > was then\n gained[#gained + 1] = { kind = 'money', text = ('%s +%s'):format(account, money - was) }\n elseif money < was then\n lost[#lost + 1] = ('%s -%s'):format(account, was - money)\n end\n end\n end\n for name, count in pairs(after.items) do\n local was = before.items[name] or 0\n if count > was then gained[#gained + 1] = { kind = 'item', text = ('item %s +%s'):format(name, count - was) } end\n end\n for _, field in ipairs({ 'job', 'gang', 'group' }) do\n if after[field] ~= before[field] and after[field] ~= nil then\n gained[#gained + 1] = { kind = 'field', text = ('%s %s'):format(field, after[field]) }\n end\n end\n if after.admin and not before.admin then gained[#gained + 1] = { kind = 'field', text = 'every command' } end\n table.sort(gained, byText)\n table.sort(lost)\n return gained, lost\nend\n\n--- Whether `changes` has any of `kind`.\nlocal function has(changes, kind)\n for _, change in ipairs(changes) do\n if change.kind == kind then return true end\n end\n return false\nend\n\n--- What `player` holds once it stays the same for 2 seconds, within 10, as\n--- frameworks give new characters their starter items a moment after they\n--- log in: QBCore once its other resources had a second to load the player.\nlocal function settled(player)\n local before, stable = holdings(player), 0\n for _ = 1, 20 do\n Wait(500)\n local now = holdings(player)\n local gained, lost = diff(before, now)\n stable = (#gained == 0 and #lost == 0) and stable + 1 or 0\n if stable == 4 then return now end\n before = now\n end\n return before\nend\n\n--- The rows of each table of the database, as JSON, by the names of the tables.\nlocal function tables()\n local rows = {}\n if not started('oxmysql') then return rows end\n pcall(function()\n for _, row in ipairs(exports.oxmysql:query_async('SHOW TABLES') or {}) do\n local _, name = next(row)\n rows[name] = json.encode(exports.oxmysql:query_async(('SELECT * FROM `%s`'):format(name)))\n end\n end)\n return rows\nend\n\n--- The tables whose rows differ from `before` to `after`.\nlocal function changed(before, after)\n local found = {}\n for name, rows in pairs(after) do\n if before[name] ~= rows then found[#found + 1] = name end\n end\n table.sort(found)\n return found\nend\n\nlocal function entities()\n return #GetAllVehicles() + #GetAllPeds() + #GetAllObjects()\nend\n\n--- An item that the server knows of, for arguments that name items.\nlocal function anItem()\n local name\n pcall(function()\n if oxInventory then\n name = next(exports.ox_inventory:Items())\n elseif framework == 'qb' then\n name = next(coreOf().Shared.Items)\n elseif framework == 'esx' then\n name = next(coreOf().Items)\n end\n end)\n return name or 'water'\nend\n\nit('audits the events that clients may trigger', function()\n while GetGameTimer() < 3000 do Wait(100) end\n local events = fiverunner.events()\n\n -- Which events clients may trigger. Each resource that handles one but not\n -- for net refuses it from clients, saying so, so a scout triggers them all at\n -- once, and those that every resource of theirs refused are closed. The scout\n -- leaves, so that what the events did to it is nothing of the player's.\n local scout, why = fiverunner.connect({ name = 'scout' })\n if not scout then\n tell({ type = 'refused', reason = why })\n return\n end\n local names = {}\n for _, event in ipairs(events) do\n if event.name ~= '*' then names[#names + 1] = event.name end\n end\n local from = #fiverunner.output() + 1\n scout:run('for _, name in ipairs(...) do TriggerServerEvent(name) end', names)\n Wait(500)\n local refusals = {}\n for _, line in ipairs(fiverunner.output(from)) do\n local name = line:match('[Ee]vent (.-) was not safe for net')\n if name then refusals[name] = (refusals[name] or 0) + 1 end\n end\n if GetPlayerName(scout.id) ~= nil then scout:disconnect() end\n\n local player\n player, why = join()\n if not player then\n tell({ type = 'refused', reason = why })\n return\n end\n local item = anItem()\n local payloads = {\n {},\n { 1000000 },\n { -1000000 },\n { 'money', 1000000 },\n { 'bank', 1000000 },\n { item, 100 },\n { 'police', 4 },\n { player.id, 1000000 },\n { { amount = 1000000, count = 100, item = item, money = 1000000, price = -1000000 } },\n }\n local openEvents = 0\n for _, event in ipairs(events) do\n if event.name ~= '*' and (refusals[event.name] or 0) < #event.resources then openEvents = openEvents + 1 end\n end\n tell({ type = 'start', framework = framework, events = #events, open = openEvents, loggedIn = frameworkPlayer(player.id) ~= nil })\n local RANK = { low = 1, medium = 2, high = 3 }\n\n local held, rows, count = settled(player), tables(), entities()\n local seen = #fiverunner.output()\n for _, event in ipairs(events) do\n local name, resources = event.name, event.resources\n local open = name ~= '*' and (refusals[name] or 0) < #resources\n -- What the arguments that did the most did, of all that the event got.\n local worst\n for _, payload in ipairs(open and payloads or {}) do\n player:emit(name, table.unpack(payload))\n Wait(100)\n local errors = {}\n -- Only what the server printed since it last read, which may be long.\n local output = fiverunner.output(seen + 1)\n for _, line in ipairs(output) do\n if line:find('SCRIPT ERROR', 1, true) and not line:find(MARK, 1, true) then\n errors[#errors + 1] = line\n end\n end\n seen = seen + #output\n local effects, severity = {}, nil\n local stayed = present(player)\n if stayed then\n local now = holdings(player)\n local gained, lost = diff(held, now)\n held = now\n -- Money that comes at once may be pay that comes on its own, such as\n -- paychecks, so it counts only when the same arguments bring more.\n if has(gained, 'money') then\n player:emit(name, table.unpack(payload))\n Wait(100)\n stayed = present(player)\n local again = stayed and holdings(player) or now\n if not has((diff(now, again)), 'money') then\n for i = #gained, 1, -1 do\n if gained[i].kind == 'money' then table.remove(gained, i) end\n end\n end\n held = again\n end\n for _, gain in ipairs(gained) do effects[#effects + 1] = gain.text end\n if #gained > 0 then\n -- What the player paid for, such as an item of a shop, is a trade.\n severity = #lost > 0 and 'medium' or 'high'\n for _, loss in ipairs(lost) do effects[#effects + 1] = loss end\n end\n end\n local nowCount = entities()\n if nowCount > count then\n effects[#effects + 1] = ('%d entities'):format(nowCount - count)\n severity = severity or 'medium'\n end\n count = nowCount\n if #errors > 0 then\n effects[#effects + 1] = 'error: ' .. errors[1]\n severity = severity or 'low'\n end\n if severity and (worst == nil or RANK[severity] > RANK[worst.severity]) then\n worst = { severity = severity, args = payload, effects = effects }\n end\n if not stayed then\n tell({ type = 'dropped', event = name, resources = resources, args = payload })\n player = join()\n if not player then return end\n payloads[8][1] = player.id\n -- What joining did is no effect of the event, whose other arguments\n -- would only drop the new player, such as by a ban, as anti-cheats do.\n held, rows, count = settled(player), tables(), entities()\n seen = seen + #fiverunner.output(seen + 1)\n break\n end\n end\n -- The rows of the database, once all the arguments went, as reading them\n -- takes far longer than the rest: whichever changed them, the event writes\n -- what clients send.\n if open then\n local nowRows = tables()\n local tablesChanged = changed(rows, nowRows)\n rows = nowRows\n if #tablesChanged > 0 then\n local effect = 'database: ' .. table.concat(tablesChanged, ', ')\n if worst and RANK[worst.severity] >= RANK.medium then\n worst.effects[#worst.effects + 1] = effect\n else\n worst = { severity = 'medium', effects = { effect, table.unpack(worst and worst.effects or {}) } }\n end\n end\n end\n if worst then\n tell({ type = 'finding', event = name, resources = resources, args = worst.args, severity = worst.severity, effects = worst.effects })\n end\n tell({ type = 'event', event = name, resources = resources, open = open })\n -- What the event left behind, such as copies of the objects of frameworks,\n -- is garbage that generational collection lets grow on servers of many resources.\n collectgarbage()\n end\n tell({ type = 'end' })\nend, 3600000)\n";
|
|
102758
102758
|
//#endregion
|
|
102759
102759
|
//#region src/cli/audit.ts
|
|
102760
|
-
var USAGE$
|
|
102760
|
+
var USAGE$5 = `Usage: fiverunner audit
|
|
102761
102761
|
|
|
102762
102762
|
Audits the project in this directory as cheaters would: a player triggers
|
|
102763
102763
|
each event of the server that clients may trigger, with the arguments that
|
|
@@ -102788,13 +102788,13 @@ var HEADINGS = {
|
|
|
102788
102788
|
*/
|
|
102789
102789
|
async function audit(args, host) {
|
|
102790
102790
|
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
102791
|
-
host.stdout(USAGE$
|
|
102791
|
+
host.stdout(USAGE$5);
|
|
102792
102792
|
return 0;
|
|
102793
102793
|
}
|
|
102794
102794
|
const json = args.includes("--json");
|
|
102795
102795
|
const unknown = args.find((arg) => arg !== "--json");
|
|
102796
102796
|
if (unknown !== void 0) {
|
|
102797
|
-
host.stderr(`fiverunner: unknown argument ${unknown}\n\n${USAGE$
|
|
102797
|
+
host.stderr(`fiverunner: unknown argument ${unknown}\n\n${USAGE$5}`);
|
|
102798
102798
|
return 2;
|
|
102799
102799
|
}
|
|
102800
102800
|
const project = openProject(host.cwd);
|
|
@@ -102813,7 +102813,8 @@ async function audit(args, host) {
|
|
|
102813
102813
|
const record = recordOf(line);
|
|
102814
102814
|
if (record?.type === "start") {
|
|
102815
102815
|
open = record.open;
|
|
102816
|
-
|
|
102816
|
+
const which = open === 1 ? "event" : `${open} events`;
|
|
102817
|
+
host.stderr(`Triggering the ${which} that clients may trigger, of the ${record.events} that the server handles\n`);
|
|
102817
102818
|
} else if (record?.type === "event" && record.open && ++tried % 10 === 0 && tried < open) host.stderr(` ${tried} of ${open}\n`);
|
|
102818
102819
|
};
|
|
102819
102820
|
const result = await runFile({
|
|
@@ -103173,7 +103174,7 @@ function listen(server, port) {
|
|
|
103173
103174
|
}
|
|
103174
103175
|
//#endregion
|
|
103175
103176
|
//#region src/cli/dev.ts
|
|
103176
|
-
var USAGE$
|
|
103177
|
+
var USAGE$4 = `Usage: fiverunner dev [options]
|
|
103177
103178
|
|
|
103178
103179
|
Runs the project in this directory in the dev UI, in the browser: its
|
|
103179
103180
|
server, with its console and its resources, and players that connect to
|
|
@@ -103193,11 +103194,11 @@ Options:
|
|
|
103193
103194
|
*/
|
|
103194
103195
|
async function dev(args, host) {
|
|
103195
103196
|
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
103196
|
-
host.stdout(USAGE$
|
|
103197
|
+
host.stdout(USAGE$4);
|
|
103197
103198
|
return 0;
|
|
103198
103199
|
}
|
|
103199
103200
|
const fail = (why) => {
|
|
103200
|
-
host.stderr(`fiverunner: ${why}\n\n${USAGE$
|
|
103201
|
+
host.stderr(`fiverunner: ${why}\n\n${USAGE$4}`);
|
|
103201
103202
|
return 2;
|
|
103202
103203
|
};
|
|
103203
103204
|
let port = DEFAULT_PORT;
|
|
@@ -103235,7 +103236,7 @@ async function dev(args, host) {
|
|
|
103235
103236
|
var types_default = "---@meta\n\n-- What the tests of FiveRunner have, for the Lua language server:\n-- https://github.com/DariusHQ/fiverunner/blob/main/docs/testing.md\n\n---@class Identity\n---@field name? string\n---@field guid? string\n---@field endpoint? string\n---@field identifiers? string[]\n---@field tokens? string[]\n---@field card? table What the player answers the adaptive cards of deferrals with\n\n---@class Player\n---@field id integer The id of the player on the server, its source\n---@field name string\nlocal Player = {}\n\n--- Runs Lua code in the client of the player, with `...` as its arguments, and returns what it returns.\n---@param code string\n---@param ... any\n---@return any ...\nfunction Player:run(code, ...) end\n\n--- Triggers an event on the server from the client, and returns once the server got it.\n---@param event string\n---@param ... any\nfunction Player:emit(event, ...) end\n\n--- The arguments of each `event` that the client got from the server, in the order they came.\n---@param event string\n---@return any[][]\nfunction Player:received(event) end\n\n--- What the client printed, line by line, as `[channel] text`, without colors, from line `from` on, when given.\n---@param from? integer\n---@return string[]\nfunction Player:output(from) end\n\n--- Disconnects the player, and returns once the server dropped it.\n---@param reason? string\nfunction Player:disconnect(reason) end\n\n--- What the client sent the NUI frame of `resource` with SendNUIMessage, in the order it sent them.\n---@param resource string\n---@return table[]\nfunction Player:nuiMessages(resource) end\n\n--- Calls a NUI callback, as the page of the resource does, and returns what it answered, and the status.\n---@param resource string\n---@param name string\n---@param data? table\n---@return any answer\n---@return integer status\nfunction Player:nuiCallback(resource, name, data) end\n\n--- Whether NUI has the focus on the client, and whether it shows the cursor.\n---@return boolean focused\n---@return boolean cursor\nfunction Player:nuiFocus() end\n\n--- Has a key of the keyboard of the client, such as `E` or `LMENU`, go down.\n---@param key string\nfunction Player:keyDown(key) end\n\n--- Has a key of the keyboard of the client go up.\n---@param key string\nfunction Player:keyUp(key) end\n\n--- Has a key go down, and up a frame later.\n---@param key string\nfunction Player:press(key) end\n\n--- Mocks a native on the client, for the rest of the test.\n---@param name string\n---@param fn function\nfunction Player:mock(name, fn) end\n\n---@class Expectation\n---@field never Expectation Negates the matchers\n---@field toBe fun(expected: any)\n---@field toEqual fun(expected: any)\n---@field toBeTruthy fun()\n---@field toBeFalsy fun()\n---@field toBeNil fun()\n---@field toBeGreaterThan fun(n: number)\n---@field toBeGreaterThanOrEqual fun(n: number)\n---@field toBeLessThan fun(n: number)\n---@field toBeLessThanOrEqual fun(n: number)\n---@field toBeCloseTo fun(n: number, digits?: integer)\n---@field toContain fun(item: any)\n---@field toContainEqual fun(item: any)\n---@field toHaveLength fun(n: integer)\n---@field toMatch fun(pattern: string)\n---@field toThrow fun(text?: string)\n\n---@class fiverunnerlib\nfiverunner = {}\n\n--- Connects a player, and returns it once its client started the resources of the server, or nil and why the server refused it.\n---@param identity? Identity\n---@return Player? player\n---@return string? reason\nfunction fiverunner.connect(identity) end\n\n--- What the server printed, line by line, as `[channel] text`, without colors, from line `from` on, when given.\n---@param from? integer\n---@return string[]\nfunction fiverunner.output(from) end\n\n--- The events that the resources of the server handle, in the order they first registered for them, those of tests left out.\n---@return { name: string, resources: string[] }[]\nfunction fiverunner.events() end\n\n--- Calls `check` at each tick until it returns something truthy, and returns that.\n---@generic T\n---@param check fun(): T\n---@param timeout? integer In milliseconds\n---@return T\nfunction fiverunner.waitFor(check, timeout) end\n\n--- Has `fn` run in place of a native on the server, for every resource, for the rest of the test.\n---@param name string\n---@param fn function\nfunction fiverunner.mock(name, fn) end\n\n--- Groups tests.\n---@param name string\n---@param fn fun()\nfunction describe(name, fn) end\n\n--- Adds a test, which fails if it does not end within `timeout` milliseconds: 5000 unless given.\n---@param name string\n---@param fn fun()\n---@param timeout? integer\nfunction it(name, fn, timeout) end\n\n--- Adds a test.\n---@param name string\n---@param fn fun()\n---@param timeout? integer\nfunction test(name, fn, timeout) end\n\n--- Adds a hook that runs before each test of the describe it is in, or of the file.\n---@param fn fun()\nfunction beforeEach(fn) end\n\n--- Adds a hook that runs after each test of the describe it is in, or of the file.\n---@param fn fun()\nfunction afterEach(fn) end\n\n--- Checks a value with a matcher, which is called with a dot.\n---@param value any\n---@return Expectation\nfunction expect(value) end\n";
|
|
103236
103237
|
//#endregion
|
|
103237
103238
|
//#region src/cli/init.ts
|
|
103238
|
-
var USAGE$
|
|
103239
|
+
var USAGE$3 = `Usage: fiverunner init
|
|
103239
103240
|
|
|
103240
103241
|
Adds tests to the project in this directory: a test file, which checks
|
|
103241
103242
|
that the project starts without script errors, what the Lua language
|
|
@@ -103298,11 +103299,11 @@ end)
|
|
|
103298
103299
|
*/
|
|
103299
103300
|
function init(args, host) {
|
|
103300
103301
|
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
103301
|
-
host.stdout(USAGE$
|
|
103302
|
+
host.stdout(USAGE$3);
|
|
103302
103303
|
return 0;
|
|
103303
103304
|
}
|
|
103304
103305
|
if (args.length > 0) {
|
|
103305
|
-
host.stderr(`fiverunner: unknown argument ${args[0]}\n\n${USAGE$
|
|
103306
|
+
host.stderr(`fiverunner: unknown argument ${args[0]}\n\n${USAGE$3}`);
|
|
103306
103307
|
return 2;
|
|
103307
103308
|
}
|
|
103308
103309
|
const project = openProject(host.cwd);
|
|
@@ -103329,34 +103330,6 @@ function init(args, host) {
|
|
|
103329
103330
|
return 0;
|
|
103330
103331
|
}
|
|
103331
103332
|
//#endregion
|
|
103332
|
-
//#region src/cli/junit.ts
|
|
103333
|
-
/** What XML 1.0 takes of `text`, with what it reads as markup escaped, for attributes and text alike. */
|
|
103334
|
-
function escaped(text) {
|
|
103335
|
-
return text.replace(/[^\t\n\r --�\u{10000}-\u{10ffff}]/gu, "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
103336
|
-
}
|
|
103337
|
-
var seconds = (milliseconds) => (milliseconds / 1e3).toFixed(3);
|
|
103338
|
-
function testCase(path, { names, status, failures, duration }) {
|
|
103339
|
-
const attributes = `classname="${escaped(path)}" name="${escaped(names.join(" > "))}" time="${seconds(duration)}"`;
|
|
103340
|
-
if (status === "skipped") return ` <testcase ${attributes}><skipped/></testcase>\n`;
|
|
103341
|
-
if (failures.length === 0) return ` <testcase ${attributes}/>\n`;
|
|
103342
|
-
return ` <testcase ${attributes}>\n${failures.map(({ message }) => {
|
|
103343
|
-
return ` <failure message="${escaped(message.split("\n", 1)[0] ?? "")}">${escaped(message)}</failure>\n`;
|
|
103344
|
-
}).join("")} </testcase>\n`;
|
|
103345
|
-
}
|
|
103346
|
-
function testSuite({ path, tests, failure }) {
|
|
103347
|
-
const failed = tests.filter(({ status }) => status === "failed").length;
|
|
103348
|
-
const skipped = tests.filter(({ status }) => status === "skipped").length;
|
|
103349
|
-
const time = tests.reduce((sum, { duration }) => sum + duration, 0);
|
|
103350
|
-
const loading = failure === void 0 ? "" : ` <testcase classname="${escaped(path)}" name="(did not load)" time="0.000">\n <failure message="${escaped(failure.message.split("\n", 1)[0] ?? "")}">${escaped(failure.message)}</failure>\n </testcase>\n`;
|
|
103351
|
-
const counts = `tests="${tests.length + (failure === void 0 ? 0 : 1)}" failures="${failed + (failure === void 0 ? 0 : 1)}" skipped="${skipped}"`;
|
|
103352
|
-
const cases = tests.map((test) => testCase(path, test)).join("");
|
|
103353
|
-
return ` <testsuite name="${escaped(path)}" ${counts} time="${seconds(time)}">\n${loading}${cases} </testsuite>\n`;
|
|
103354
|
-
}
|
|
103355
|
-
/** What became of test files as a JUnit report, which tools such as test summaries of CI read. */
|
|
103356
|
-
function junitReport(files) {
|
|
103357
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${files.map(testSuite).join("")}</testsuites>\n`;
|
|
103358
|
-
}
|
|
103359
|
-
//#endregion
|
|
103360
103333
|
//#region src/cli/report.ts
|
|
103361
103334
|
var PASSED = String.fromCodePoint(10003);
|
|
103362
103335
|
var FAILED = String.fromCodePoint(215);
|
|
@@ -103500,8 +103473,36 @@ function property(text) {
|
|
|
103500
103473
|
return data(text).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
103501
103474
|
}
|
|
103502
103475
|
//#endregion
|
|
103476
|
+
//#region src/cli/junit.ts
|
|
103477
|
+
/** What XML 1.0 takes of `text`, with what it reads as markup escaped, for attributes and text alike. */
|
|
103478
|
+
function escaped(text) {
|
|
103479
|
+
return text.replace(/[^\t\n\r --�\u{10000}-\u{10ffff}]/gu, "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
103480
|
+
}
|
|
103481
|
+
var seconds = (milliseconds) => (milliseconds / 1e3).toFixed(3);
|
|
103482
|
+
function testCase(path, { names, status, failures, duration }) {
|
|
103483
|
+
const attributes = `classname="${escaped(path)}" name="${escaped(names.join(" > "))}" time="${seconds(duration)}"`;
|
|
103484
|
+
if (status === "skipped") return ` <testcase ${attributes}><skipped/></testcase>\n`;
|
|
103485
|
+
if (failures.length === 0) return ` <testcase ${attributes}/>\n`;
|
|
103486
|
+
return ` <testcase ${attributes}>\n${failures.map(({ message }) => {
|
|
103487
|
+
return ` <failure message="${escaped(message.split("\n", 1)[0] ?? "")}">${escaped(message)}</failure>\n`;
|
|
103488
|
+
}).join("")} </testcase>\n`;
|
|
103489
|
+
}
|
|
103490
|
+
function testSuite({ path, tests, failure }) {
|
|
103491
|
+
const failed = tests.filter(({ status }) => status === "failed").length;
|
|
103492
|
+
const skipped = tests.filter(({ status }) => status === "skipped").length;
|
|
103493
|
+
const time = tests.reduce((sum, { duration }) => sum + duration, 0);
|
|
103494
|
+
const loading = failure === void 0 ? "" : ` <testcase classname="${escaped(path)}" name="(did not load)" time="0.000">\n <failure message="${escaped(failure.message.split("\n", 1)[0] ?? "")}">${escaped(failure.message)}</failure>\n </testcase>\n`;
|
|
103495
|
+
const counts = `tests="${tests.length + (failure === void 0 ? 0 : 1)}" failures="${failed + (failure === void 0 ? 0 : 1)}" skipped="${skipped}"`;
|
|
103496
|
+
const cases = tests.map((test) => testCase(path, test)).join("");
|
|
103497
|
+
return ` <testsuite name="${escaped(path)}" ${counts} time="${seconds(time)}">\n${loading}${cases} </testsuite>\n`;
|
|
103498
|
+
}
|
|
103499
|
+
/** What became of test files as a JUnit report, which tools such as test summaries of CI read. */
|
|
103500
|
+
function junitReport(files) {
|
|
103501
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${files.map(testSuite).join("")}</testsuites>\n`;
|
|
103502
|
+
}
|
|
103503
|
+
//#endregion
|
|
103503
103504
|
//#region src/cli/test.ts
|
|
103504
|
-
var USAGE$
|
|
103505
|
+
var USAGE$2 = `Usage: fiverunner test [paths...]
|
|
103505
103506
|
|
|
103506
103507
|
Runs the tests of the project in this directory: the *.test.lua files in
|
|
103507
103508
|
the paths given, or in the whole project. The project is a resource when
|
|
@@ -103528,7 +103529,7 @@ var UsageError = class extends Error {};
|
|
|
103528
103529
|
*/
|
|
103529
103530
|
async function test(args, host) {
|
|
103530
103531
|
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
103531
|
-
host.stdout(USAGE$
|
|
103532
|
+
host.stdout(USAGE$2);
|
|
103532
103533
|
return 0;
|
|
103533
103534
|
}
|
|
103534
103535
|
const watching = args.some((arg) => arg === "-w" || arg === "--watch");
|
|
@@ -103540,13 +103541,13 @@ async function test(args, host) {
|
|
|
103540
103541
|
const at = rest.indexOf("--junit");
|
|
103541
103542
|
const junit = at === -1 ? void 0 : rest[at + 1];
|
|
103542
103543
|
if (at !== -1 && (junit === void 0 || junit.startsWith("-"))) {
|
|
103543
|
-
host.stderr(`fiverunner: --junit needs a file\n\n${USAGE$
|
|
103544
|
+
host.stderr(`fiverunner: --junit needs a file\n\n${USAGE$2}`);
|
|
103544
103545
|
return 2;
|
|
103545
103546
|
}
|
|
103546
103547
|
const paths = at === -1 ? rest : rest.toSpliced(at, 2);
|
|
103547
103548
|
const option = paths.find((arg) => arg.startsWith("-"));
|
|
103548
103549
|
if (option !== void 0) {
|
|
103549
|
-
host.stderr(`fiverunner: unknown option ${option}\n\n${USAGE$
|
|
103550
|
+
host.stderr(`fiverunner: unknown option ${option}\n\n${USAGE$2}`);
|
|
103550
103551
|
return 2;
|
|
103551
103552
|
}
|
|
103552
103553
|
const reports = {
|
|
@@ -103677,6 +103678,328 @@ function kindOf(source, path) {
|
|
|
103677
103678
|
return entry.directory ? "directory" : "file";
|
|
103678
103679
|
}
|
|
103679
103680
|
//#endregion
|
|
103681
|
+
//#region src/cli/mcp.ts
|
|
103682
|
+
var USAGE$1 = `Usage: fiverunner mcp
|
|
103683
|
+
|
|
103684
|
+
Serves the Model Context Protocol on stdin and stdout, for AI assistants
|
|
103685
|
+
such as Claude Code, Cursor and VS Code: its tools run the FiveM project in
|
|
103686
|
+
the directory that the assistant starts it in, with no GTA V. It runs until
|
|
103687
|
+
stdin ends.
|
|
103688
|
+
|
|
103689
|
+
Options:
|
|
103690
|
+
-h, --help Show this help
|
|
103691
|
+
`;
|
|
103692
|
+
/** The versions of the protocol that the server speaks, newest first. */
|
|
103693
|
+
var VERSIONS = [
|
|
103694
|
+
"2025-06-18",
|
|
103695
|
+
"2025-03-26",
|
|
103696
|
+
"2024-11-05"
|
|
103697
|
+
];
|
|
103698
|
+
var INSTRUCTIONS = `FiveRunner emulates the FiveM server and its clients, so its tools run the FiveM project of the directory that this server runs in, without GTA V or FXServer: describe_project tells what is in it, run_tests runs its *.test.lua files, run_lua runs Lua as a test in a fresh server of it, and audit triggers its events as cheaters do. The guide of tests is https://github.com/DariusHQ/fiverunner/blob/main/docs/testing.md.`;
|
|
103699
|
+
var TOOLS = [
|
|
103700
|
+
{
|
|
103701
|
+
name: "describe_project",
|
|
103702
|
+
description: "Tells what the FiveM project is, a resource or a server, the resources that its server started and their states, what they printed as they started, such as script errors, and its test files.",
|
|
103703
|
+
inputSchema: {
|
|
103704
|
+
type: "object",
|
|
103705
|
+
properties: {}
|
|
103706
|
+
}
|
|
103707
|
+
},
|
|
103708
|
+
{
|
|
103709
|
+
name: "run_tests",
|
|
103710
|
+
description: "Runs the Lua tests of the project, its *.test.lua files or those in `paths`, each test in a fresh server, and tells which passed and failed, with what the server and players printed for each failure.",
|
|
103711
|
+
inputSchema: {
|
|
103712
|
+
type: "object",
|
|
103713
|
+
properties: { paths: {
|
|
103714
|
+
type: "array",
|
|
103715
|
+
items: { type: "string" },
|
|
103716
|
+
description: "Test files, or directories of them, relative to the project"
|
|
103717
|
+
} }
|
|
103718
|
+
}
|
|
103719
|
+
},
|
|
103720
|
+
{
|
|
103721
|
+
name: "run_lua",
|
|
103722
|
+
description: `Runs Lua code as the body of a test, in a fresh server of the project, and tells whether it failed and what the server and players printed. The code runs as a server script of the resource of tests, once the resources of the project started, with exports, events, natives and ExecuteCommand as on a server, and FiveRunner's API:
|
|
103723
|
+
- fiverunner.connect({ name = 'Ann' }) connects a player, whose client runs the client scripts, and returns it, or nil and why the server refused it
|
|
103724
|
+
- player:emit(event, ...) triggers a server event from its client; player:run(code, ...) runs Lua in its client; player:received(event) gives what it got of an event; player:nuiCallback(resource, name, data) calls a NUI callback as its page does; player:output() gives what its client printed
|
|
103725
|
+
- fiverunner.output() gives what the server printed; fiverunner.waitFor(check, timeout) waits until check() is truthy; fiverunner.events() lists the events that resources handle; fiverunner.mock(native, fn) mocks a native
|
|
103726
|
+
- expect(value).toBe(other), .toEqual(other), .toContain(item), .toBeTruthy() check values, and print(...) shows anything
|
|
103727
|
+
- Wait(ms) passes time, which takes no real time. Connect players once GetGameTimer() passed 3000, as frameworks expect players to join started servers.`,
|
|
103728
|
+
inputSchema: {
|
|
103729
|
+
type: "object",
|
|
103730
|
+
properties: {
|
|
103731
|
+
code: {
|
|
103732
|
+
type: "string",
|
|
103733
|
+
description: "The Lua code to run"
|
|
103734
|
+
},
|
|
103735
|
+
timeout: {
|
|
103736
|
+
type: "number",
|
|
103737
|
+
description: "Milliseconds of game time that the code may take, 60000 by default"
|
|
103738
|
+
}
|
|
103739
|
+
},
|
|
103740
|
+
required: ["code"]
|
|
103741
|
+
}
|
|
103742
|
+
},
|
|
103743
|
+
{
|
|
103744
|
+
name: "audit",
|
|
103745
|
+
description: "Triggers each event of the server that clients may trigger, from a player logged in with a character, with the arguments that cheaters give, and tells what any client gains, such as money, items or a job, what it changes in the database, and what breaks. It takes minutes on servers of many resources.",
|
|
103746
|
+
inputSchema: {
|
|
103747
|
+
type: "object",
|
|
103748
|
+
properties: {}
|
|
103749
|
+
}
|
|
103750
|
+
}
|
|
103751
|
+
];
|
|
103752
|
+
/** The script that describe_project runs in a server of the project. */
|
|
103753
|
+
var DESCRIBE = `it('describes the project', function()
|
|
103754
|
+
for i = 0, GetNumResources() - 1 do
|
|
103755
|
+
local name = GetResourceByFindIndex(i)
|
|
103756
|
+
if name ~= 'fiverunner' then print(('resource %s %s'):format(name, GetResourceState(name))) end
|
|
103757
|
+
end
|
|
103758
|
+
end)
|
|
103759
|
+
`;
|
|
103760
|
+
/**
|
|
103761
|
+
* Serves the Model Context Protocol on the input and the stdout of the
|
|
103762
|
+
* host until its input ends, and returns the exit code: 0, or 2 when the
|
|
103763
|
+
* arguments make no sense to it or it has no input.
|
|
103764
|
+
*/
|
|
103765
|
+
async function mcp(args, host) {
|
|
103766
|
+
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
103767
|
+
host.stdout(USAGE$1);
|
|
103768
|
+
return 0;
|
|
103769
|
+
}
|
|
103770
|
+
if (args.length > 0) {
|
|
103771
|
+
host.stderr(`fiverunner: unknown arguments: ${args.join(" ")}\n\n${USAGE$1}`);
|
|
103772
|
+
return 2;
|
|
103773
|
+
}
|
|
103774
|
+
if (host.input === void 0) {
|
|
103775
|
+
host.stderr("fiverunner: mcp reads the requests of its client from stdin, which it has none of\n");
|
|
103776
|
+
return 2;
|
|
103777
|
+
}
|
|
103778
|
+
const send = (message) => host.stdout(`${JSON.stringify({
|
|
103779
|
+
jsonrpc: "2.0",
|
|
103780
|
+
...message
|
|
103781
|
+
})}\n`);
|
|
103782
|
+
let tools = Promise.resolve();
|
|
103783
|
+
const answer = async (message) => {
|
|
103784
|
+
const { id, method, params = {} } = message;
|
|
103785
|
+
if (id === void 0) return;
|
|
103786
|
+
switch (method) {
|
|
103787
|
+
case "initialize": {
|
|
103788
|
+
const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
|
|
103789
|
+
return send({
|
|
103790
|
+
id,
|
|
103791
|
+
result: {
|
|
103792
|
+
protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0],
|
|
103793
|
+
capabilities: { tools: {} },
|
|
103794
|
+
serverInfo: {
|
|
103795
|
+
name: "fiverunner",
|
|
103796
|
+
version: package_default.version
|
|
103797
|
+
},
|
|
103798
|
+
instructions: INSTRUCTIONS
|
|
103799
|
+
}
|
|
103800
|
+
});
|
|
103801
|
+
}
|
|
103802
|
+
case "ping": return send({
|
|
103803
|
+
id,
|
|
103804
|
+
result: {}
|
|
103805
|
+
});
|
|
103806
|
+
case "tools/list": return send({
|
|
103807
|
+
id,
|
|
103808
|
+
result: { tools: TOOLS }
|
|
103809
|
+
});
|
|
103810
|
+
case "tools/call": {
|
|
103811
|
+
const token = params._meta?.progressToken;
|
|
103812
|
+
let progress = 0;
|
|
103813
|
+
const tell = (text) => {
|
|
103814
|
+
if (token === void 0) return;
|
|
103815
|
+
for (const line of text.split("\n").filter((line) => line.trim() !== "")) send({
|
|
103816
|
+
method: "notifications/progress",
|
|
103817
|
+
params: {
|
|
103818
|
+
progressToken: token,
|
|
103819
|
+
progress: ++progress,
|
|
103820
|
+
message: line.trim()
|
|
103821
|
+
}
|
|
103822
|
+
});
|
|
103823
|
+
};
|
|
103824
|
+
tools = tools.then(async () => {
|
|
103825
|
+
const name = typeof params.name === "string" ? params.name : "";
|
|
103826
|
+
const given = params.arguments ?? {};
|
|
103827
|
+
if (!TOOLS.some((tool) => tool.name === name)) return send({
|
|
103828
|
+
id,
|
|
103829
|
+
error: {
|
|
103830
|
+
code: -32602,
|
|
103831
|
+
message: `Unknown tool: ${name}`
|
|
103832
|
+
}
|
|
103833
|
+
});
|
|
103834
|
+
let result;
|
|
103835
|
+
try {
|
|
103836
|
+
result = await call(name, given, host, tell);
|
|
103837
|
+
} catch (error) {
|
|
103838
|
+
result = {
|
|
103839
|
+
text: `FiveRunner failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
103840
|
+
isError: true
|
|
103841
|
+
};
|
|
103842
|
+
}
|
|
103843
|
+
send({
|
|
103844
|
+
id,
|
|
103845
|
+
result: {
|
|
103846
|
+
content: [{
|
|
103847
|
+
type: "text",
|
|
103848
|
+
text: result.text
|
|
103849
|
+
}],
|
|
103850
|
+
isError: result.isError === true
|
|
103851
|
+
}
|
|
103852
|
+
});
|
|
103853
|
+
});
|
|
103854
|
+
return tools;
|
|
103855
|
+
}
|
|
103856
|
+
default: return send({
|
|
103857
|
+
id,
|
|
103858
|
+
error: {
|
|
103859
|
+
code: -32601,
|
|
103860
|
+
message: `Method not found: ${String(method)}`
|
|
103861
|
+
}
|
|
103862
|
+
});
|
|
103863
|
+
}
|
|
103864
|
+
};
|
|
103865
|
+
const pending = [];
|
|
103866
|
+
let buffered = "";
|
|
103867
|
+
const decoder = new TextDecoder();
|
|
103868
|
+
for await (const chunk of host.input) {
|
|
103869
|
+
buffered += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
103870
|
+
const lines = buffered.split("\n");
|
|
103871
|
+
buffered = lines.pop();
|
|
103872
|
+
for (const line of lines) {
|
|
103873
|
+
if (line.trim() === "") continue;
|
|
103874
|
+
let message;
|
|
103875
|
+
try {
|
|
103876
|
+
message = JSON.parse(line);
|
|
103877
|
+
} catch {
|
|
103878
|
+
send({
|
|
103879
|
+
id: null,
|
|
103880
|
+
error: {
|
|
103881
|
+
code: -32700,
|
|
103882
|
+
message: "Parse error"
|
|
103883
|
+
}
|
|
103884
|
+
});
|
|
103885
|
+
continue;
|
|
103886
|
+
}
|
|
103887
|
+
pending.push(answer(message));
|
|
103888
|
+
}
|
|
103889
|
+
}
|
|
103890
|
+
await Promise.all(pending);
|
|
103891
|
+
await tools;
|
|
103892
|
+
return 0;
|
|
103893
|
+
}
|
|
103894
|
+
/** Runs the tool `name` with `given`, telling `tell` how it goes, as the commands of the tool tell stderr. */
|
|
103895
|
+
async function call(name, given, host, tell) {
|
|
103896
|
+
let out = "";
|
|
103897
|
+
const captured = {
|
|
103898
|
+
...host,
|
|
103899
|
+
color: false,
|
|
103900
|
+
stdout: (text) => {
|
|
103901
|
+
out += text;
|
|
103902
|
+
},
|
|
103903
|
+
stderr: (text) => {
|
|
103904
|
+
out += text;
|
|
103905
|
+
tell(text);
|
|
103906
|
+
}
|
|
103907
|
+
};
|
|
103908
|
+
switch (name) {
|
|
103909
|
+
case "run_tests": {
|
|
103910
|
+
const code = await test(Array.isArray(given.paths) ? given.paths.map(String) : [], captured);
|
|
103911
|
+
return {
|
|
103912
|
+
text: out,
|
|
103913
|
+
isError: code === 2
|
|
103914
|
+
};
|
|
103915
|
+
}
|
|
103916
|
+
case "audit": {
|
|
103917
|
+
const code = await audit([], captured);
|
|
103918
|
+
return {
|
|
103919
|
+
text: out,
|
|
103920
|
+
isError: code === 2
|
|
103921
|
+
};
|
|
103922
|
+
}
|
|
103923
|
+
case "run_lua": {
|
|
103924
|
+
if (typeof given.code !== "string") return {
|
|
103925
|
+
text: "run_lua needs code, a string",
|
|
103926
|
+
isError: true
|
|
103927
|
+
};
|
|
103928
|
+
const timeout = typeof given.timeout === "number" && given.timeout > 0 ? given.timeout : 6e4;
|
|
103929
|
+
const result = await runOn(host, ".fiverunner/mcp.test.lua", `it('runs the code', function() ${given.code}\nend, ${Math.trunc(timeout)})\n`);
|
|
103930
|
+
if (typeof result === "string") return {
|
|
103931
|
+
text: result,
|
|
103932
|
+
isError: true
|
|
103933
|
+
};
|
|
103934
|
+
return { text: describeRun(result) };
|
|
103935
|
+
}
|
|
103936
|
+
case "describe_project": {
|
|
103937
|
+
const project = openProject(host.cwd);
|
|
103938
|
+
if (project === void 0) return {
|
|
103939
|
+
text: NO_PROJECT,
|
|
103940
|
+
isError: true
|
|
103941
|
+
};
|
|
103942
|
+
const result = await runOn(host, ".fiverunner/describe.test.lua", DESCRIBE);
|
|
103943
|
+
if (typeof result === "string") return {
|
|
103944
|
+
text: result,
|
|
103945
|
+
isError: true
|
|
103946
|
+
};
|
|
103947
|
+
const output = result.tests.flatMap((test) => test.output);
|
|
103948
|
+
const resources = output.filter((line) => line.process === "server" && line.text.startsWith("resource ")).map((line) => ` ${line.text.slice(9)}`);
|
|
103949
|
+
const printed = output.filter((line) => !routine(line) && !line.text.startsWith("resource ")).map(showLine);
|
|
103950
|
+
const tests = findTestFiles(project.source);
|
|
103951
|
+
return { text: [
|
|
103952
|
+
project.kind === "resource" ? `A resource, ${project.name}, which tests run in a server of its own.` : `A server, which runs ${project.commands.length > 0 ? "its server.cfg" : "no server.cfg, so it starts no resource"}.`,
|
|
103953
|
+
"",
|
|
103954
|
+
"Resources, and their states once the server started:",
|
|
103955
|
+
...resources.length > 0 ? resources : [" none"],
|
|
103956
|
+
"",
|
|
103957
|
+
printed.length > 0 ? "What the server printed as it started:" : "The server printed nothing of note as it started.",
|
|
103958
|
+
...printed.slice(-400).map((line) => ` ${line}`),
|
|
103959
|
+
"",
|
|
103960
|
+
tests.length > 0 ? `Test files: ${tests.join(", ")}` : "No test files: fiverunner init adds one."
|
|
103961
|
+
].join("\n") };
|
|
103962
|
+
}
|
|
103963
|
+
default: return {
|
|
103964
|
+
text: `Unknown tool: ${name}`,
|
|
103965
|
+
isError: true
|
|
103966
|
+
};
|
|
103967
|
+
}
|
|
103968
|
+
}
|
|
103969
|
+
var NO_PROJECT = "There is no FiveM project in the directory of the server: a resource has an fxmanifest.lua or a __resource.lua, and a server a resources directory.";
|
|
103970
|
+
/** Runs a test file of `source` on the project of the host, or tells why it cannot. */
|
|
103971
|
+
async function runOn(host, path, source) {
|
|
103972
|
+
const project = openProject(host.cwd);
|
|
103973
|
+
if (project === void 0) return NO_PROJECT;
|
|
103974
|
+
const [cfxlua, sqlite, quickjs] = await Promise.all([
|
|
103975
|
+
host.cfxlua(),
|
|
103976
|
+
host.sqlite(),
|
|
103977
|
+
host.quickjs?.()
|
|
103978
|
+
]);
|
|
103979
|
+
return runFile({
|
|
103980
|
+
path,
|
|
103981
|
+
source
|
|
103982
|
+
}, {
|
|
103983
|
+
cfxlua,
|
|
103984
|
+
sqlite,
|
|
103985
|
+
project,
|
|
103986
|
+
...quickjs === void 0 ? {} : { quickjs }
|
|
103987
|
+
});
|
|
103988
|
+
}
|
|
103989
|
+
/** What run_lua tells of a run: whether the code failed, and the last lines that the processes printed. */
|
|
103990
|
+
function describeRun(result) {
|
|
103991
|
+
const [ran] = result.tests;
|
|
103992
|
+
const failures = result.failure !== void 0 ? [result.failure] : ran?.failures ?? [];
|
|
103993
|
+
const lines = (ran?.output ?? result.output).filter((line) => !routine(line)).map(showLine);
|
|
103994
|
+
const shown = lines.slice(-400);
|
|
103995
|
+
return [
|
|
103996
|
+
failures.length === 0 ? "The code ran without failing." : `The code failed:\n${failures.map((failure) => ` ${failure.message}`).join("\n")}`,
|
|
103997
|
+
"",
|
|
103998
|
+
lines.length === 0 ? "Nothing was printed." : `What the server and players printed${shown.length < lines.length ? `, its last ${shown.length} lines of ${lines.length}` : ""}:`,
|
|
103999
|
+
...shown.map((line) => ` ${line}`)
|
|
104000
|
+
].join("\n");
|
|
104001
|
+
}
|
|
104002
|
+
//#endregion
|
|
103680
104003
|
//#region src/cli/main.ts
|
|
103681
104004
|
var USAGE = `Usage: fiverunner <command> [options]
|
|
103682
104005
|
|
|
@@ -103685,6 +104008,7 @@ Commands:
|
|
|
103685
104008
|
dev Run the project in this directory in the dev UI, in the browser
|
|
103686
104009
|
init Add a test file and a GitHub workflow to the project in this directory
|
|
103687
104010
|
audit Trigger the events of the server as cheaters would, and tell what they change
|
|
104011
|
+
mcp Serve the Model Context Protocol on stdio, for AI assistants
|
|
103688
104012
|
|
|
103689
104013
|
Options:
|
|
103690
104014
|
-h, --help Show this help
|
|
@@ -103701,6 +104025,7 @@ async function main(args, host) {
|
|
|
103701
104025
|
if (first === "dev") return dev(rest, host);
|
|
103702
104026
|
if (first === "init") return init(rest, host);
|
|
103703
104027
|
if (first === "audit") return audit(rest, host);
|
|
104028
|
+
if (first === "mcp") return mcp(rest, host);
|
|
103704
104029
|
if (rest.length === 0 && (first === void 0 || first === "-h" || first === "--help")) {
|
|
103705
104030
|
host.stdout(`FiveRunner ${package_default.version}\n\n${USAGE}`);
|
|
103706
104031
|
return 0;
|
|
@@ -103719,6 +104044,7 @@ process.exitCode = await main(process.argv.slice(2), {
|
|
|
103719
104044
|
env: process.env,
|
|
103720
104045
|
stdout: (text) => process.stdout.write(text),
|
|
103721
104046
|
stderr: (text) => process.stderr.write(text),
|
|
104047
|
+
input: process.stdin,
|
|
103722
104048
|
color: process.stdout.isTTY === true && process.env.NO_COLOR === void 0,
|
|
103723
104049
|
cfxlua: () => WebAssembly.compile(readFileSync(new URL("cfxlua.wasm", import.meta.url))),
|
|
103724
104050
|
wasm: () => readFileSync(new URL("cfxlua.wasm", import.meta.url)),
|