Compare commits

...

25 Commits

Author SHA1 Message Date
MayaTheShy
9835556e6f diag: add verbose network listener logging to trace command delivery
Manager logs: listener start, every modem_message received, channel
match/mismatch, command acceptance count, handler result.
Client logs: every sendToMaster call with type and commandId.
2026-03-29 16:39:29 -04:00
MayaTheShy
033da0933c fix: point package repository to stable branch 2026-03-29 16:22:25 -04:00
MayaTheShy
1336590241 feat: notify manager to refresh cache after inventory dump 2026-03-22 23:00:07 -04:00
MayaTheShy
78e7c92893 Change turtle dashboard default URL to turtles.spatulaa.com 2026-03-22 22:52:57 -04:00
MayaTheShy
e59b6c1832 feat: upgrade Node.js version to 20-alpine in Dockerfiles and improve healthcheck command 2026-03-22 22:44:26 -04:00
MayaTheShy
e343ab8b4e feat: enhance smelter tab event handling and optimize stock lookup logic 2026-03-22 22:31:39 -04:00
MayaTheShy
b9b69a4966 feat: sync disabled recipes and smelting state from client, improve broadcast logic 2026-03-22 22:31:35 -04:00
MayaTheShy
bdc9b3f291 feat: sync full smelting state to master for persistence 2026-03-22 22:31:31 -04:00
MayaTheShy
8b8279878a Fix craft dispatch: add turtle attach/detach handlers, re-scan on craft, log failures
- Add peripheral_attach handler to auto-detect crafting turtle connecting after boot
- Update peripheral_detach handler to clear craftTurtleName when turtle disconnects
- Re-scan peripherals for turtle in display.lua craft handler if none known
- Add log.warn when craft turtle check fails (was silent notification only)
- Sync ctx.craftTurtleOk from broadcastState so display.lua has current value
2026-03-22 22:26:10 -04:00
MayaTheShy
f095e18e95 fix: simplify crafting turtle detection logic by removing unnecessary method checks 2026-03-22 22:19:08 -04:00
MayaTheShy
904d4ec7f7 feat: improve modem detection logic with fallback for wireless modems and ensure channels are open 2026-03-22 22:09:28 -04:00
MayaTheShy
baaa724595 Fix crafting turtle: remove self-wrap, use chest push/pull instead
Same fix as mining turtle — peripheral.wrap(selfName) returns nil
on some turtles. Replaced:
- selfInv.pushItems(chest, slot) → chest.pullItems(selfName, slot)
- selfInv.pullItems(chest, slot, n, dst) → chest.pushItems(selfName, slot, n, dst)
2026-03-22 22:05:07 -04:00
MayaTheShy
8ff4203152 feat: enhance auto-refuel functionality with manager integration and fallback mechanism 2026-03-22 21:43:02 -04:00
MayaTheShy
72c978ee75 feat: add find_item functionality to locate items in chests 2026-03-22 21:42:58 -04:00
MayaTheShy
d25f25ee52 Fix mining turtle: use pull instead of push to avoid self-wrap failure
peripheral.wrap(selfName) can return nil on some turtles even when
connected. Switch to wrapping the remote chest and calling
chest.pullItems(selfName, slot) instead.
2026-03-22 21:38:13 -04:00
MayaTheShy
2adfa7f7a3 Add mining turtle for infinite cobblestone generators
New miningTurtle.lua: sits on top of a cobble gen, continuously
digs down, and pushes items into networked chests via wired modem.
Features:
- Configurable mine interval, dump threshold, fuel slot
- Auto-dumps inventory when slots fill or timer expires
- Auto-refuels from designated fuel slot
- Remote reboot via SYSTEM_CHANNEL
- Stats display (mined, dumped, uptime, fuel)
- Persistent config in usr/config/inventory-manager/.miner_config

Also adds:
- startup/miner.lua: auto-update + launch script for standalone use
- .package: new 'Mining Turtle' role (option 4) in setup wizard
- autorun/startup.lua: detects .miner_config and launches miningTurtle
2026-03-22 21:32:42 -04:00
MayaTheShy
96afe8dcb7 Fix silent Docker crash: recursive chown, db error handling
- Entrypoint: chown -R /data (not just the directory) so existing
  volume files owned by root become writable by node user
- Entrypoint: add echo logging so startup progress is visible
- db.js: verify /data is writable before opening SQLite
- db.js: wrap Database() constructor in try-catch with clear error
  message instead of crashing silently at ESM import time
2026-03-22 21:22:30 -04:00
MayaTheShy
1b0b1c570d Fix health check: use wget -qO instead of --spider, increase start_period to 30s 2026-03-22 21:15:43 -04:00
MayaTheShy
73e157cc13 fix: remove healthcheck configuration from server service 2026-03-22 21:08:46 -04:00
MayaTheShy
18bdac03b1 Fix config.lua crash: move _configPath before first use
_configPath was called at lines 26/28 (CACHE_FILE, DISABLED_RECIPES_FILE)
but only defined at line 79. Moved the definition to the top of the
function body so it exists before the first call.
2026-03-22 20:55:33 -04:00
MayaTheShy
173a0a9f95 Persist config files across Opus package updates
The Opus package manager deletes the entire package directory on
update (fs.delete(packageDir)) before re-downloading files. This
wiped all config files (.manager_config, .client_config, etc.) that
were stored inside packages/inventory-manager/.

Fix: add _configPath() helper to every program that resolves config
file paths to usr/config/inventory-manager/ when running under Opus,
which lives outside the package directory and survives updates.
Falls back to the local _path() for standalone (non-Opus) use.

Updated files:
- inventoryManager.lua, manager/config.lua, inventoryClient.lua,
  inventoryWebBridge.lua, dropperController.lua, craftingTurtle.lua
- .package install script: saves configs to usr/config/inventory-manager/
- autorun/startup.lua: checks both persistent and package dirs
- startup/client.lua: uses persistent dir for .client_setup/.dropper_config
- web/server/Dockerfile: switch health check to wget (from prior fix)
2026-03-22 20:51:31 -04:00
MayaTheShy
01eef4eead fix: correct typo in HEALTHCHECK start-period option 2026-03-22 20:44:57 -04:00
MayaTheShy
a499446366 fix: use wget for Docker health check instead of node
Spawning a full Node process for health checks is slow on Alpine and
can exceed the 3s timeout, causing the container to be marked unhealthy.
Use wget (built into Alpine) instead, and increase start_period to 15s.
2026-03-22 20:42:37 -04:00
MayaTheShy
a6bf84d6b8 fix: update order popup background color to gray 2026-03-22 20:42:08 -04:00
MayaTheShy
aaaf25350c fix: correct item drawing in main page refresh 2026-03-22 20:29:51 -04:00
18 changed files with 805 additions and 91 deletions

View File

@@ -1,14 +1,15 @@
{
title = "Inventory Manager",
description = "Automated inventory management system for CC:Tweaked. Tracks items across networked storage, crafting turtles, furnaces, and alerts. Includes web dashboard via bridge computer.",
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/main/",
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/stable/",
exclude = {
"^web/", "^__tests__/", "^startup/",
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",
"%.bak$", "^listDevicesByType%.lua$",
},
install = [[
local pkgDir = fs.combine("packages", "inventory-manager")
local cfgDir = "usr/config/inventory-manager"
if not fs.isDir(cfgDir) then fs.makeDir(cfgDir) end
local function ask(prompt, default)
if default and #default > 0 then
write(prompt .. " [" .. default .. "]: ")
@@ -27,9 +28,10 @@
print(" 1) Inventory Manager (main controller)")
print(" 2) Inventory Client (display-only)")
print(" 3) Web Bridge (HTTP forwarder)")
print(" 4) Skip setup")
print(" 4) Mining Turtle (cobble miner)")
print(" 5) Skip setup")
print("")
write("Choice (1/2/3/4): ")
write("Choice (1/2/3/4/5): ")
local choice = read()
if choice == "1" then
@@ -47,14 +49,14 @@
dropperName = dropperName,
barrelName = barrelName,
}
local f = fs.open(fs.combine(pkgDir, ".manager_config"), "w")
local f = fs.open(fs.combine(cfgDir, ".manager_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved manager config.")
if serverUrl and #serverUrl > 0 then
local bcfg = { serverUrl = serverUrl }
local bf = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
local bf = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
bf.write(textutils.serialiseJSON(bcfg))
bf.close()
print("Saved web bridge config.")
@@ -75,7 +77,7 @@
if dropperName and #dropperName > 0 then cfg.dropperName = dropperName end
if barrelName and #barrelName > 0 then cfg.barrelName = barrelName end
local f = fs.open(fs.combine(pkgDir, ".client_config"), "w")
local f = fs.open(fs.combine(cfgDir, ".client_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved client config.")
@@ -86,11 +88,26 @@
local serverUrl = ask("Web server URL", "http://localhost")
local cfg = { serverUrl = serverUrl }
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
local f = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved web bridge config.")
elseif choice == "4" then
print("")
print("-- Mining Turtle Configuration --")
local mineInterval = ask("Mine interval in seconds", "0.5")
local dumpThreshold = ask("Dump when N slots full", "14")
local cfg = {
mineInterval = tonumber(mineInterval) or 0.5,
dumpThreshold = tonumber(dumpThreshold) or 14,
}
local f = fs.open(fs.combine(cfgDir, ".miner_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved miner config.")
else
print("Skipped — edit config files manually later.")
end

View File

@@ -8,18 +8,26 @@ local peripheral = _G.peripheral
local shell = _ENV.shell
local BASE = 'packages/inventory-manager'
local CFG = 'usr/config/inventory-manager'
-------------------------------------------------
-- Determine role from config files written during install
-------------------------------------------------
local function cfgExists(name)
return fs.exists(fs.combine(CFG, name))
or fs.exists(fs.combine(BASE, name))
end
local role
if fs.exists(fs.combine(BASE, '.manager_config')) then
if cfgExists('.manager_config') then
role = 'manager'
elseif fs.exists(fs.combine(BASE, '.client_config')) then
elseif cfgExists('.client_config') then
role = 'client'
elseif fs.exists(fs.combine(BASE, '.webbridge_config')) then
elseif cfgExists('.webbridge_config') then
role = 'bridge'
elseif cfgExists('.miner_config') then
role = 'miner'
elseif _G.turtle then
role = 'turtle'
end
@@ -63,6 +71,7 @@ local programs = {
client = 'inventoryClient.lua',
bridge = 'inventoryWebBridge.lua',
turtle = 'craftingTurtle.lua',
miner = 'miningTurtle.lua',
}
local program = fs.combine(BASE, programs[role])

View File

@@ -31,7 +31,17 @@ local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
local TURTLE_CONFIG_FILE = _path(".turtle_config")
-- Persistent config path (survives Opus package updates)
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
local TURTLE_CONFIG_FILE = _configPath(".turtle_config")
local function loadConfig()
if not fs.exists(TURTLE_CONFIG_FILE) then return end
@@ -95,13 +105,6 @@ print("[OK] Network name: " .. selfName)
modem.open(CRAFT_CHANNEL)
print("[OK] Listening on channel " .. CRAFT_CHANNEL)
-- Wrap our own inventory peripheral for pullItems/pushItems
local selfInv = peripheral.wrap(selfName)
if not selfInv then
return fatal("[ERR] Cannot wrap own peripheral: " .. selfName .. "\n Make sure the wired modem is connected.")
end
print("[OK] Self-inventory peripheral ready")
print("")
print("Waiting for craft commands from master...")
print("")
@@ -111,15 +114,20 @@ print("")
-------------------------------------------------
local function clearInventory(chests)
-- Push all items from all 16 turtle slots back to chests
-- Pull all items from turtle slots into chests (using chest.pullItems)
local cleared = 0
for slot = 1, 16 do
if turtle.getItemCount(slot) > 0 then
for _, chestName in ipairs(chests) do
local ok, n = pcall(selfInv.pushItems, chestName, slot)
if ok and n and n > 0 then
cleared = cleared + n
break
local chest = peripheral.wrap(chestName)
if chest and chest.pullItems then
local ok, n = pcall(chest.pullItems, selfName, slot)
if ok and n and n > 0 then
cleared = cleared + n
if turtle.getItemCount(slot) == 0 then
break
end
end
end
end
end
@@ -180,7 +188,13 @@ local function handleCraftCommand(message)
print(string.format("[CRAFT] Pulling %s from %s slot %d -> turtle slot %d",
itemName, chestName, chestSlot, turtleSlot))
local ok, n = pcall(selfInv.pullItems, chestName, chestSlot, count, turtleSlot)
local chest = peripheral.wrap(chestName)
if not chest then
print(string.format("[CRAFT] Cannot wrap chest: %s", chestName))
allPlaced = false
break
end
local ok, n = pcall(chest.pushItems, selfName, chestSlot, count, turtleSlot)
if ok and n and n > 0 then
placedItems[turtleSlot] = itemName
print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, n, turtleSlot))

View File

@@ -11,7 +11,18 @@ local POLL_INTERVAL = 0.5 -- how often to check the dropper
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
local CONFIG_FILE = fs.combine(_baseDir, ".dropper_config")
-- Persistent config path: survives Opus package updates
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return fs.combine(_baseDir, rel)
end
local CONFIG_FILE = _configPath(".dropper_config")
if fs.exists(CONFIG_FILE) then
local f = fs.open(CONFIG_FILE, "r")

View File

@@ -30,6 +30,16 @@ local DROPPER_ANNOUNCE_INTERVAL = 30 -- seconds between dropper announcements
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
-- Persistent config path: survives Opus package updates
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
-- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
local _ccDofile = dofile
@@ -39,7 +49,7 @@ local function dofile(path) -- luacheck: ignore
else error(err, 2) end
end
local CLIENT_CONFIG_FILE = _path(".client_config")
local CLIENT_CONFIG_FILE = _configPath(".client_config")
-------------------------------------------------
-- Modules
@@ -95,10 +105,14 @@ end
-------------------------------------------------
local function sendToMaster(message)
if not networkModem then return end
if not networkModem then
log.warn("NET", "sendToMaster: no modem, dropping %s", tostring(message.type))
return
end
if not message.commandId then
message.commandId = newCommandId()
end
log.info("NET", "TX -> ch %d: type=%s cmdId=%s", ORDER_CHANNEL, tostring(message.type), tostring(message.commandId))
networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message)
end
@@ -227,9 +241,12 @@ function ops.orderItem(itemName, amount)
end
function ops.saveDisabledRecipes()
-- Client doesn't persist locally; the master handles persistence.
-- We forward the current disabledRecipes state to master via
-- toggle/enable/disable commands (already handled by display.lua events).
-- Sync full smelting state to master so it persists and applies.
sendToMaster({
type = "sync_disabled_recipes",
disabledRecipes = state.disabledRecipes,
smeltingPaused = state.smeltingPaused,
})
end
function ops.craftItem(recipeIdx)

View File

@@ -15,6 +15,17 @@
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
-- Persistent config path: survives Opus package updates by storing
-- user data in usr/config/inventory-manager/ instead of the package dir.
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
-- Write crash info to a file so we can always read it
local function _crashLog(err)
local f = fs.open(_path(".crash.log"), "w")
@@ -42,7 +53,7 @@ end
local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua"))
local itemDB = dofile(_path("lib/itemDB.lua"))
itemDB.init(_path(".item_names.db"))
itemDB.init(_configPath(".item_names.db"))
-------------------------------------------------
-- Load modules (factory pattern → shared context)
@@ -137,11 +148,12 @@ local function broadcastState()
craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName),
}
if state.configDirty then
payload.smeltable = cfg.SMELTABLE
payload.craftable = cfg.CRAFTABLE
state.configDirty = false
end
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
ctx.craftTurtleOk = payload.craftTurtleOk
payload.smeltable = cfg.SMELTABLE
payload.craftable = cfg.CRAFTABLE
state.configDirty = false
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
state.lastBroadcastVersion = state.stateVersion
@@ -183,24 +195,39 @@ local function main()
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
end
-- Find modem for client communication
-- Find wired modem for client/turtle communication
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then
ctx.networkModem = peripheral.wrap(name)
ctx.networkModemName = name
ctx.networkModem.open(cfg.ORDER_CHANNEL)
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
break
local m = peripheral.wrap(name)
-- Prefer wired modem (has getNameLocal); skip wireless
if m.isWireless and not m.isWireless() then
ctx.networkModem = m
ctx.networkModemName = name
ctx.networkModem.open(cfg.ORDER_CHANNEL)
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
break
elseif not ctx.networkModem then
-- Fallback: use wireless if no wired available
ctx.networkModem = m
ctx.networkModemName = name
end
end
end
if ctx.networkModem then
log.info("INIT", "Network modem: %s", ctx.networkModemName)
-- Ensure channels are open even on fallback modem
ctx.networkModem.open(cfg.ORDER_CHANNEL)
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
log.info("INIT", "Network modem: %s (wired=%s)", ctx.networkModemName,
tostring(ctx.networkModem.isWireless and not ctx.networkModem.isWireless()))
else
log.warn("INIT", "No modem found for client sync")
end
-- Detect crafting turtle on network
-- The actual craft message goes on CRAFT_CHANNEL which only the crafting
-- turtle listens to, so we just need any turtle name for presence checks.
for _, name in ipairs(peripheral.getNames()) do
if name:match("^turtle_") then
ctx.craftTurtleName = name
@@ -479,6 +506,22 @@ local function main()
ops.invalidateWrapCache(name)
ops.invalidatePeripheralCaches()
log.info("DETACH", "%s", name)
if name == ctx.craftTurtleName then
ctx.craftTurtleName = nil
log.warn("DETACH", "Crafting turtle disconnected")
end
end
end
end,
-- Task 11b: Peripheral attach handler (auto-detect crafting turtle)
function()
while true do
local event, name = os.pullEvent("peripheral_attach")
if name and name:match("^turtle_") and not ctx.craftTurtleName then
ctx.craftTurtleName = name
log.info("ATTACH", "Crafting turtle detected: %s", name)
pcall(broadcastState)
end
end
end,
@@ -498,14 +541,23 @@ local function main()
-- Task 13: Network order/command listener
function()
if not ctx.networkModem then
log.warn("NET", "No modem — listener disabled")
while true do sleep(3600) end
end
log.info("NET", "Listener started on channel %d (modem: %s)", cfg.ORDER_CHANNEL, ctx.networkModemName or "?")
local cmdCount = 0
while true do
log.info("NET", "Waiting for modem_message... (handled %d so far)", cmdCount)
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
log.info("NET", "Got modem_message: side=%s ch=%d reply=%d type=%s",
tostring(side), channel or -1, replyChannel or -1,
type(message) == "table" and tostring(message.type) or type(message))
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then
if isCommandDuplicate(message.commandId) then
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
else
cmdCount = cmdCount + 1
log.info("NET", "Command #%d accepted: type=%s cmdId=%s", cmdCount, tostring(message.type), tostring(message.commandId))
recordCommandId(message.commandId)
cleanupCommandIds()
local handlerOk, handlerErr = pcall(function()
@@ -665,6 +717,21 @@ local function main()
state.needsRedraw = true
pcall(broadcastState)
elseif message.type == "sync_disabled_recipes" then
if message.disabledRecipes then
state.disabledRecipes = message.disabledRecipes
end
if message.smeltingPaused ~= nil then
state.smeltingPaused = message.smeltingPaused
end
ops.saveDisabledRecipes()
log.info("NET", "Synced smelting state from client")
state.configDirty = true
state.bumpStateVersion()
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
elseif message.type == "learn_crafting_recipe" and message.output and message.count and message.grid then
cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
cfg.refreshRecipes()
@@ -694,13 +761,54 @@ local function main()
state.bumpStateVersion()
end
pcall(broadcastState)
elseif message.type == "find_item" and message.items then
-- Return chest+slot locations for the first matching item
-- message.items = list of item names to search (in priority order)
-- message.limit = max items to return info for (default 64)
local limit = message.limit or 64
local results = {}
for _, itemName in ipairs(message.items) do
if cache.catalogue[itemName] then
for _, source in ipairs(cache.catalogue[itemName]) do
local chest = ops.wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == itemName then
table.insert(results, {
chest = source.chest,
slot = slot,
name = itemName,
count = slotItem.count,
})
if #results >= limit then break end
end
end
end
if #results >= limit then break end
end
end
if #results > 0 then break end -- found fuel, stop searching
end
log.info("NET", "find_item: found %d source(s)", #results)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "find_item_result",
commandId = message.commandId,
results = results,
})
end)
end
end) -- pcall handler
if not handlerOk then
log.error("NET", "Handler error: %s", tostring(handlerErr))
else
log.info("NET", "Command #%d handled OK", cmdCount)
end
end -- idempotency else
else
log.info("NET", "Ignored: ch=%d (want %d) or not table", channel or -1, cfg.ORDER_CHANNEL)
end
end
end

View File

@@ -24,7 +24,17 @@ local ORDER_CHANNEL = 4201
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
local CONFIG_FILE = _path(".webbridge_config")
-- Persistent config path: survives Opus package updates
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
local CONFIG_FILE = _configPath(".webbridge_config")
local API_KEY = nil -- optional API key for server auth
local function loadConfig()

View File

@@ -6,6 +6,16 @@ return function(log, _path)
-- Fall back to CWD-relative if _path not provided (standalone use)
if not _path then _path = function(p) return p end end
-- Persistent config path: survives Opus package updates
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
local C = {}
-------------------------------------------------
@@ -22,9 +32,9 @@ C.SMELT_RESERVE = 128
C.DEFRAG_INTERVAL = 600
C.COMPOST_INTERVAL = 3
C.ALERT_INTERVAL = 15
C.CACHE_FILE = _path(".inventory_cache")
C.CACHE_FILE = _configPath(".inventory_cache")
C.SMELTER_MONITOR_SIDE = "top"
C.DISABLED_RECIPES_FILE = _path(".disabled_recipes")
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
-- Network
C.BROADCAST_CHANNEL = 4200
@@ -71,7 +81,7 @@ C.SLOT_OUTPUT = 3
-- Config file loader
-------------------------------------------------
local CONFIG_FILE = _path(".manager_config")
local CONFIG_FILE = _configPath(".manager_config")
function C.loadConfig()
if not fs.exists(CONFIG_FILE) then return end
@@ -124,7 +134,7 @@ C.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua"))
-- Recipe book: merges built-in recipes + user-learned recipes
local recipeBook = dofile(_path("lib/recipeBook.lua"))
recipeBook.init(_path(".recipes.db"))
recipeBook.init(_configPath(".recipes.db"))
recipeBook.loadLegacyCrafting(dofile(_path("data/craftable.lua")))
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
C.recipeBook = recipeBook

View File

@@ -479,10 +479,10 @@ local function buildMainPage()
-- Order quantity popup (full-screen overlay; starts disabled)
orderPopup = UI.Window {
x = 1, y = 1, ex = -1, ey = -1,
backgroundColor = colors.black,
backgroundColor = colors.gray,
enable = function() end, -- toggled manually
draw = function(self)
self:clear(colors.black)
self:clear(colors.gray)
self._zones = {}
local dw = math.min(self.width - 2, 30)
@@ -618,7 +618,7 @@ local function buildMainPage()
searchQuery = searchQuery .. " "
end
D.refreshItemGrid()
selfitem.total
self.searchRow:draw()
self.footerBar:draw()
self:sync()
return true
@@ -858,6 +858,25 @@ local function buildSmelterPage()
selectedBackgroundColor = colors.purple,
unselectedBackgroundColor = colors.gray,
eventHandler = function(self, event)
if event.type == 'tab_change' then
local titleMap = {
Status = 'status', Smelt = 'smelt',
Craft = 'craft', Missing = 'missing',
}
if event.tab and event.tab.text then
smelterView = titleMap[event.tab.text] or smelterView
end
D.refreshSmelterData()
local page = self.parent
if page then
page.smelterFooter:draw()
page.bottomBar:draw()
end
end
return UI.Tabs.eventHandler(self, event)
end,
-- Status tab
statusTab = UI.Tab {
index = 1,
@@ -1094,17 +1113,7 @@ local function buildSmelterPage()
},
eventHandler = function(self, event)
if event.type == 'tab_change' then
local tabMap = { 'status', 'smelt', 'craft', 'missing' }
if event.current then
smelterView = tabMap[event.current] or smelterView
end
D.refreshSmelterData()
self.smelterFooter:draw()
self.bottomBar:draw()
-- fall through to default handler for tab switching
elseif event.type == 'enable_all' then
if event.type == 'enable_all' then
state.disabledRecipes = {}
log.debug("UI", "All recipes enabled")
ops.saveDisabledRecipes()
@@ -1141,10 +1150,22 @@ local function buildSmelterPage()
local recipeIdx = event.selected.idx
local recipe = cfg.CRAFTABLE[recipeIdx]
if recipe then
-- Re-scan for turtle if none known
if not ctx.craftTurtleName then
for _, pName in ipairs(peripheral.getNames()) do
if pName:match("^turtle_") then
ctx.craftTurtleName = pName
log.info("CRAFT", "Turtle found on re-scan: %s", pName)
break
end
end
end
local turtleOk = ctx.craftTurtleOk
or (ctx.craftTurtleName
and peripheral.isPresent(ctx.craftTurtleName))
if not turtleOk then
log.warn("CRAFT", "No crafting turtle! (name=%s)",
tostring(ctx.craftTurtleName))
self.notification:error("No crafting turtle!")
return true
end
@@ -1234,22 +1255,24 @@ function D.refreshSmelterData()
end
smelterPage.tabs.statusTab.grid:setValues(statusValues)
-- Smelt tab
-- Smelt tab: build stock lookup from itemList (works for both manager and client)
state.ensureItemList()
local stockLookup = {}
for _, item in ipairs(cache.itemList or {}) do
stockLookup[item.name] = item.total
end
local recipeList = {}
for inputName, recipe in pairs(cfg.SMELTABLE) do
local short = shortName(inputName)
local resultShort = shortName(recipe.result)
local types = ""
if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end
if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end
if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end
local fSet = recipe.furnaceSet or {}
if fSet["minecraft:furnace"] then types = types .. "F" end
if fSet["minecraft:smoker"] then types = types .. "S" end
if fSet["minecraft:blast_furnace"] then types = types .. "B" end
local enabled = not state.disabledRecipes[inputName]
local inStorage = 0
if cache.catalogue[inputName] then
for _, s in ipairs(cache.catalogue[inputName]) do
inStorage = inStorage + s.total
end
end
local inStorage = stockLookup[inputName] or 0
table.insert(recipeList, {
inputName = inputName,
inputShort = short,

414
miningTurtle.lua Normal file
View File

@@ -0,0 +1,414 @@
-- Mining Turtle Script
-- Sits on top of an infinite cobblestone generator.
-- Continuously mines the block below and pushes items into
-- networked storage via wired modem.
-- Requires a wired modem attached to the turtle.
local shell = _ENV.shell
local function fatal(msg)
printError(msg)
print("\nPress any key to exit...")
os.pullEvent("key")
return
end
-------------------------------------------------
-- Default configuration (overridden by .miner_config)
-------------------------------------------------
local MINE_INTERVAL = 0.5 -- seconds between dig attempts
local DUMP_THRESHOLD = 14 -- dump when this many slots are occupied
local DUMP_INTERVAL = 30 -- force dump every N seconds even if not full
local FUEL_SLOT = 16 -- slot used for pulling/burning fuel
local FUEL_THRESHOLD = 200 -- pull fuel when below this level
local SYSTEM_CHANNEL = 4205 -- remote reboot channel
local ORDER_CHANNEL = 4201 -- channel to talk to manager
local FUEL_REQUEST_TIMEOUT = 5 -- seconds to wait for manager reply
-- Fuel items the turtle will look for in storage (best first)
local FUEL_ITEMS = {
"minecraft:coal",
"minecraft:charcoal",
"minecraft:coal_block",
"minecraft:blaze_rod",
"minecraft:dried_kelp_block",
}
-------------------------------------------------
-- Load config from file if present
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
-- Persistent config path (survives Opus package updates)
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
local CONFIG_FILE = _configPath(".miner_config")
local function loadConfig()
if not fs.exists(CONFIG_FILE) then return end
local f = fs.open(CONFIG_FILE, "r")
local data = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, data)
if not ok or not cfg then
print("[WARN] Failed to parse " .. CONFIG_FILE)
return
end
if cfg.mineInterval then MINE_INTERVAL = cfg.mineInterval end
if cfg.dumpThreshold then DUMP_THRESHOLD = cfg.dumpThreshold end
if cfg.dumpInterval then DUMP_INTERVAL = cfg.dumpInterval end
if cfg.fuelSlot then FUEL_SLOT = cfg.fuelSlot end
print("[CONFIG] Loaded from " .. CONFIG_FILE)
end
loadConfig()
-------------------------------------------------
-- Setup
-------------------------------------------------
print("=================================")
print(" Mining Turtle (Cobble Miner)")
print("=================================")
print("")
if not turtle then
return fatal("[ERR] Not a turtle!")
end
-- Find wired modem and get our network name
local modem = nil
local modemSide = nil
local selfName = nil
for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
if peripheral.getType(side) == "modem" then
local m = peripheral.wrap(side)
if m.getNameLocal then
local name = m.getNameLocal()
if name then
modem = m
modemSide = side
selfName = name
break
end
end
end
end
if not modem or not selfName then
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
end
print("[OK] Modem: " .. modemSide)
print("[OK] Network name: " .. selfName)
print("[OK] Mine interval: " .. MINE_INTERVAL .. "s")
print("[OK] Dump threshold: " .. DUMP_THRESHOLD .. " slots")
print("")
-------------------------------------------------
-- Find chests on the network to dump into
-------------------------------------------------
local function getChests()
local chests = {}
for _, name in ipairs(peripheral.getNames()) do
local ptype = peripheral.getType(name)
if ptype == "minecraft:chest" or ptype == "minecraft:barrel" or
ptype == "minecraft:shulker_box" then
table.insert(chests, name)
end
end
return chests
end
-------------------------------------------------
-- Dump inventory into networked storage
-- Uses pull approach: wrap the remote chest and call
-- chest.pullItems(selfName, slot) — avoids needing
-- to wrap the turtle's own peripheral.
-------------------------------------------------
local function dumpInventory()
local chests = getChests()
if #chests == 0 then
print("[DUMP] No chests on network!")
return 0
end
local totalPushed = 0
for slot = 1, 16 do
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
for _, chestName in ipairs(chests) do
local chest = peripheral.wrap(chestName)
if chest and chest.pullItems then
local ok, n = pcall(chest.pullItems, selfName, slot)
if ok and n and n > 0 then
totalPushed = totalPushed + n
if turtle.getItemCount(slot) == 0 then
break -- slot empty, move to next
end
end
end
end
end
end
-- Notify manager to refresh its cache so counts update immediately
if totalPushed > 0 then
pcall(function()
modem.transmit(ORDER_CHANNEL, ORDER_CHANNEL, { type = "scan" })
end)
end
return totalPushed
end
-------------------------------------------------
-- Count occupied inventory slots
-------------------------------------------------
local function occupiedSlots()
local count = 0
for slot = 1, 16 do
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
count = count + 1
end
end
return count
end
-------------------------------------------------
-- Auto-refuel: ask manager for fuel, pull directly
-------------------------------------------------
--- Try to burn whatever is already in FUEL_SLOT
local function burnFuelSlot()
if FUEL_SLOT <= 0 then return false end
local prev = turtle.getSelectedSlot()
turtle.select(FUEL_SLOT)
if turtle.getItemCount() > 0 then
local ok = turtle.refuel()
turtle.select(prev)
return ok
end
turtle.select(prev)
return false
end
--- Ask the inventory manager where fuel items are
local function askManagerForFuel()
local replyChannel = ORDER_CHANNEL + 100 + os.getComputerID()
modem.open(replyChannel)
modem.transmit(ORDER_CHANNEL, replyChannel, {
type = "find_item",
items = FUEL_ITEMS,
limit = 1,
})
local deadline = os.clock() + FUEL_REQUEST_TIMEOUT
local result = nil
while os.clock() < deadline do
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
local event, p1, p2, p3, p4 = os.pullEvent()
if event == "modem_message" and p2 == replyChannel then
if type(p4) == "table" and p4.type == "find_item_result" then
result = p4.results
break
end
elseif event == "timer" and p1 == timerId then
break
end
end
modem.close(replyChannel)
return result
end
--- Pull fuel from a specific chest+slot (told by manager)
local function pullFuelFromSource(source)
if FUEL_SLOT <= 0 then return false end
local chest = peripheral.wrap(source.chest)
if not chest then return false end
local ok, n = pcall(chest.pushItems, selfName, source.slot, 64, FUEL_SLOT)
if ok and n and n > 0 then
print(string.format("[FUEL] Pulled %s x%d from %s", source.name, n, source.chest))
return true
end
return false
end
local function pullFuelFromStorage()
-- Ask manager first (it knows exactly where fuel is)
local sources = askManagerForFuel()
if sources and #sources > 0 then
for _, source in ipairs(sources) do
if pullFuelFromSource(source) then return true end
end
end
-- Fallback: scan chests directly (in case manager is offline)
local fuelSet = {}
for _, name in ipairs(FUEL_ITEMS) do fuelSet[name] = true end
local chests = getChests()
for _, chestName in ipairs(chests) do
local chest = peripheral.wrap(chestName)
if chest and chest.list then
for slot, item in pairs(chest.list() or {}) do
if fuelSet[item.name] then
local ok, n = pcall(chest.pushItems, selfName, slot, 64, FUEL_SLOT)
if ok and n and n > 0 then
print(string.format("[FUEL] Pulled %s x%d (fallback)", item.name, n))
return true
end
end
end
end
end
return false
end
local function autoRefuel()
if turtle.getFuelLevel() == "unlimited" then return end
if turtle.getFuelLevel() >= FUEL_THRESHOLD then return end
-- First try burning whatever is already in the fuel slot
if burnFuelSlot() then
print("[FUEL] Burned local fuel. Level: " .. turtle.getFuelLevel())
return
end
-- Pull fresh fuel from networked storage
if pullFuelFromStorage() then
burnFuelSlot()
print("[FUEL] Refueled from storage. Level: " .. turtle.getFuelLevel())
end
end
-------------------------------------------------
-- Stats
-------------------------------------------------
local totalMined = 0
local totalDumped = 0
local startTime = os.clock()
local function printStats()
local elapsed = os.clock() - startTime
local mins = math.floor(elapsed / 60)
local secs = math.floor(elapsed % 60)
term.clear()
term.setCursorPos(1, 1)
print("=================================")
print(" Mining Turtle - Running")
print("=================================")
print(string.format(" Mined: %d blocks", totalMined))
print(string.format(" Dumped: %d items", totalDumped))
print(string.format(" Uptime: %dm %ds", mins, secs))
if turtle.getFuelLevel() ~= "unlimited" then
print(string.format(" Fuel: %d", turtle.getFuelLevel()))
end
print(string.format(" Inv used: %d/16 slots", occupiedSlots()))
print("=================================")
end
-------------------------------------------------
-- Main mining loop
-------------------------------------------------
local function mineLoop()
local lastDump = os.clock()
while true do
-- Auto-refuel if low
autoRefuel()
-- Check fuel — keep trying to pull from storage
if turtle.getFuelLevel() ~= "unlimited" and turtle.getFuelLevel() < 1 then
print("[WARN] Out of fuel! Searching storage...")
while turtle.getFuelLevel() < 1 do
if pullFuelFromStorage() then
burnFuelSlot()
end
if turtle.getFuelLevel() < 1 then
printStats()
sleep(10)
end
end
end
-- Mine the block below
if turtle.detectDown() then
local ok, err = turtle.digDown()
if ok then
totalMined = totalMined + 1
end
end
-- Dump inventory if threshold reached or timer expired
local now = os.clock()
if occupiedSlots() >= DUMP_THRESHOLD or (now - lastDump) >= DUMP_INTERVAL then
if occupiedSlots() > 0 then
local pushed = dumpInventory()
totalDumped = totalDumped + pushed
if pushed > 0 then
print(string.format("[DUMP] Pushed %d items to storage", pushed))
end
end
lastDump = os.clock()
end
-- Refresh display periodically
printStats()
sleep(MINE_INTERVAL)
end
end
-------------------------------------------------
-- Reboot listener (remote reboot support)
-------------------------------------------------
local function rebootListener()
modem.open(SYSTEM_CHANNEL)
while true do
local _, _, channel, _, message = os.pullEvent("modem_message")
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
local target = message.target or "all"
if target == "all" or target == "miner" or target == tostring(os.getComputerID()) then
print("[SYSTEM] Reboot command received.")
sleep(0.5)
os.reboot()
end
end
end
end
-------------------------------------------------
-- Entry point
-------------------------------------------------
print("Starting mining loop...")
print("Press Ctrl+T to stop.")
print("")
sleep(1)
local ok, err = pcall(function()
parallel.waitForAny(mineLoop, rebootListener)
end)
if not ok then
fatal("[ERR] Mining turtle crashed:\n" .. tostring(err))
end

View File

@@ -2,8 +2,13 @@
-- Auto-updates from git then launches inventoryClient + dropperController in parallel
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
local SETUP_FILE = ".client_setup" -- marks first-run complete
local DROPPER_CONFIG = ".dropper_config"
-- Persistent config directory (survives Opus package updates)
local PERSIST_DIR = "usr/config/inventory-manager"
if not fs.isDir(PERSIST_DIR) then fs.makeDir(PERSIST_DIR) end
local SETUP_FILE = fs.combine(PERSIST_DIR, ".client_setup")
local DROPPER_CONFIG = fs.combine(PERSIST_DIR, ".dropper_config")
-- Files to download (destination -> repo path)
local FILES = {

58
startup/miner.lua Normal file
View File

@@ -0,0 +1,58 @@
-- startup.lua for Mining Turtle
-- Auto-updates from git then launches miningTurtle.lua
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
local FILES = {
["miningTurtle.lua"] = "miningTurtle.lua",
}
-------------------------------------------------
local function download(remotePath, localPath)
local url = REPO_RAW .. "/" .. remotePath
local response = http.get(url)
if response then
local f = fs.open(localPath, "w")
f.write(response.readAll())
f.close()
response.close()
return true
end
return false
end
-------------------------------------------------
term.clear()
term.setCursorPos(1, 1)
print("==================================")
print(" Mining Turtle - Startup")
print(" Computer ID: " .. os.getComputerID())
print("==================================")
print("")
local updated, failed = 0, 0
for localPath, remotePath in pairs(FILES) do
write(" " .. localPath .. " ... ")
if download(remotePath, localPath) then
print("OK")
updated = updated + 1
else
print("FAIL")
failed = failed + 1
end
end
print("")
if failed > 0 then
print(string.format("Updated %d files, %d failed.", updated, failed))
else
print(string.format("All %d files up to date.", updated))
end
print("")
print("Starting miningTurtle...")
sleep(1)
shell.run("miningTurtle.lua")

View File

@@ -1,5 +1,5 @@
# Stage 1: Build the React app
FROM node:18-alpine AS build
FROM node:20-alpine AS build
WORKDIR /app

View File

@@ -22,7 +22,7 @@ function App() {
const [showSettings, setShowSettings] = useState(false);
const [, forceRender] = useState(0);
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}:4444`;
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || 'https://turtles.spatulaa.com';
useEffect(() => {
connect();

View File

@@ -9,12 +9,6 @@ services:
- API_KEY=${API_KEY:-}
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
interval: 10s
timeout: 3s
start_period: 5s
retries: 3
client:
build:

View File

@@ -1,9 +1,10 @@
# Node.js backend
FROM node:18-alpine
FROM node:20-alpine
# Build tools needed for better-sqlite3 native compilation
# su-exec for dropping privileges in entrypoint
RUN apk add --no-cache python3 make g++ su-exec
# libstdc++ is kept at runtime (needed by better-sqlite3 native addon)
RUN apk add --no-cache python3 make g++ su-exec libstdc++
WORKDIR /app
@@ -12,6 +13,7 @@ COPY package*.json ./
RUN npm install --omit=dev
# Remove build tools after install to keep image small
# libstdc++ and su-exec are kept for runtime
RUN apk del python3 make g++
COPY . .
@@ -26,8 +28,8 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3001/api/health', r => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

View File

@@ -1,16 +1,32 @@
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { existsSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
import { dirname } from 'path';
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
console.log(`[db] Opening database at ${DB_PATH} (uid=${process.getuid()})`);
// Ensure the directory exists
const dbDir = dirname(DB_PATH);
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
const db = new Database(DB_PATH);
// Verify directory is writable before opening SQLite
try {
accessSync(dbDir, fsConstants.W_OK);
} catch {
console.error(`[db] FATAL: directory ${dbDir} is not writable by uid ${process.getuid()}`);
process.exit(1);
}
let db;
try {
db = new Database(DB_PATH);
} catch (err) {
console.error(`[db] FATAL: failed to open database: ${err.message}`);
process.exit(1);
}
// Performance pragmas
db.pragma('journal_mode = WAL');
@@ -19,6 +35,8 @@ db.pragma('foreign_keys = ON');
db.pragma('cache_size = -8000'); // 8MB cache
db.pragma('temp_store = MEMORY');
console.log('[db] Database ready');
// ========== Schema ==========
db.exec(`

View File

@@ -1,9 +1,13 @@
#!/bin/sh
set -e
echo "[entrypoint] Starting up..."
# Ensure data directory exists and is writable by the node user
mkdir -p /data
chown node:node /data
chown -R node:node /data
echo "[entrypoint] /data permissions fixed"
# Drop privileges and exec the CMD
echo "[entrypoint] Dropping to user 'node', running: $*"
exec su-exec node "$@"