Compare commits

...

49 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
MayaTheShy
432a9feff9 Implemented the usage of the standarised ui library. 2026-03-22 20:23:06 -04:00
MayaTheShy
ee76a61240 Refactor turtle status check in smelter page
- Updated the turtle status check logic to use ctx.craftTurtleOk for determining if the crafting turtle is available.
- Adjusted the logic to fall back on checking ctx.craftTurtleName and its peripheral presence if ctx.craftTurtleOk is not set.
2026-03-22 20:20:07 -04:00
MayaTheShy
85228b134b feat: add order quantity popup for item ordering in dashboard 2026-03-22 20:16:01 -04:00
MayaTheShy
d97167b21c Add client_display.lua for network client dashboard using Opus UI
- Implemented a client dashboard mirroring manager/display.lua
- Integrated state management via master broadcasts (ctx.cache, ctx.activity)
- Enabled action commands to master through ctx.sendToMaster()
- Established UI components for inventory management and smelting operations
- Included item filtering, stock tracking, and crafting capabilities
- Designed responsive layouts for main and smelter dashboards
2026-03-22 20:15:53 -04:00
MayaTheShy
69e24e7d79 fix: update comments for clarity and organization in inventoryClient.lua 2026-03-22 20:10:41 -04:00
MayaTheShy
02248ccc38 fix: keyboard overlay hidden behind bottom bars due to z-order
Opus initChildren uses pairs() which has non-deterministic ordering.
The keyboard overlay occupies the same screen space as alertBar,
footerBar, and bottomBar. If those bars end up later in the children
array, they render on top and hide the keyboard.

Added raise() after enabling the keyboard to move it to the end
of the children array, ensuring it renders on top.
2026-03-22 20:04:51 -04:00
MayaTheShy
e67fded321 fix: client crash from barrel task early return in waitForAny
Same bug as the manager: CLIENT_BARREL_NAME == '' caused return
inside parallel.waitForAny, killing all tasks instantly.
Replace with infinite sleep when barrel is unconfigured.
2026-03-22 19:51:51 -04:00
MayaTheShy
621902b3e8 fix: correct reference to parent page in keyboard toggle event 2026-03-22 19:47:18 -04:00
MayaTheShy
380289d484 fix: improve error handling in crafting turtle script 2026-03-22 19:41:49 -04:00
MayaTheShy
d67a2fde88 feat: first-run setup wizard for client dropper config
- startup/client.lua now prompts on first run: asks if a dropper
  is next to the computer, and if so, which side it's on
- Saves config to .dropper_config (JSON with dropperSide,
  redstoneSide, enabled)
- Only launches dropperController if dropper is enabled
- dropperController.lua reads .dropper_config for its sides
  instead of hardcoding 'back'
- Delete .client_setup to re-run the setup wizard
2026-03-22 19:31:08 -04:00
MayaTheShy
215652d47c feat: reimplement on-screen keyboard for monitor search
UI.TextEntry requires keyboard input which monitors don't have.
Replaced it with the original touch-friendly approach:
- Search bar shows query text with toggle button (? / X)
- Tapping the search row opens an on-screen keyboard overlay
- 3-row QWERTY layout with Bksp, Done, Space, Clr keys
- Keyboard overlays the bottom status bars when active
- All key zones use touch hit-testing for monitor_touch events
2026-03-22 19:22:03 -04:00
MayaTheShy
b49574f39b fix: SQLite readonly error in Docker container
- Add entrypoint script that ensures /data is owned by node user
  before dropping privileges with su-exec
- Remove USER node from Dockerfile (entrypoint handles it)
- Change client depends_on to service_healthy so nginx waits for
  the server to pass its healthcheck before starting
2026-03-22 19:15:04 -04:00
MayaTheShy
d4a9441b54 fix: resize pages after re-parenting to monitor device
Page:postInit defaults parent to UI.term (small computer terminal).
Window:postInit then calls setParent() which computes all child
dimensions from that small terminal. When we later re-parent to
the monitor device, the children retain their small dimensions.
Adding resize() before setParent() forces all children to
recompute dimensions from the correct monitor size.
2026-03-22 19:09:49 -04:00
MayaTheShy
5518161adf fix: call enable() on pages after attaching to device
Opus Canvas only renders children with enabled=true. Without
calling page:enable(), all widgets were skipped during render,
resulting in a blank black monitor.
2026-03-22 19:04:20 -04:00
MayaTheShy
4c329bbfb3 debug: add raw monitor write test after sync + fix boot window visible 2026-03-22 19:01:10 -04:00
MayaTheShy
381951bd91 fix: clear boot window to prevent future monitor write interference 2026-03-22 19:00:39 -04:00
MayaTheShy
fdc3c36cd7 debug: add diagnostic logging to drawDashboard to trace rendering 2026-03-22 18:58:04 -04:00
MayaTheShy
64b3e4b069 debug: log draw errors instead of silent pcall swallowing 2026-03-22 18:52:23 -04:00
MayaTheShy
c4acc2159e fix: prevent early return in parallel tasks from killing waitForAny
Task 12 (supply chest) and Task 13 (network) returned immediately
when not configured, which caused parallel.waitForAny to exit and
the entire program to silently stop after cache build.
2026-03-22 18:49:43 -04:00
MayaTheShy
a0740b81f5 debug: xpcall entire program body, write crash log to .crash.log
Error is now saved to disk even if the window closes instantly.
Uses xpcall with debug.traceback for full stack trace.
2026-03-22 18:47:15 -04:00
MayaTheShy
82d74a01b5 debug: wrap main() in pcall to show crash error before window closes 2026-03-22 18:44:46 -04:00
MayaTheShy
bb139b4afd fix: override dofile to preserve Opus env (require was nil in sub-modules)
CC:Tweaked dofile loads files with _G as environment, but Opus
injects require into the program's sandbox _ENV. All dofile'd
modules (especially display.lua) could not see require.

Also add dropperController to apps.db.
2026-03-22 18:40:03 -04:00
MayaTheShy
40e6eab42d feat: shorten application titles in apps database and remove unused entries 2026-03-22 18:35:41 -04:00
MayaTheShy
d9b7bd32b7 feat: add exclusion for backup files and specific Lua script in package configuration 2026-03-22 18:35:33 -04:00
19 changed files with 1589 additions and 1290 deletions

View File

@@ -1,13 +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 .. "]: ")
@@ -26,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
@@ -46,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.")
@@ -74,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.")
@@ -85,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

@@ -4,6 +4,15 @@
-- Pulls ingredients from chests, crafts, and pushes results back.
-- 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 .turtle_config)
-------------------------------------------------
@@ -22,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
@@ -52,9 +71,7 @@ print("")
-- Verify this is a crafting turtle
if not turtle or not turtle.craft then
print("[ERR] No turtle.craft() available!")
print(" This must be a Crafting Turtle.")
return
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting Turtle.")
end
-- Find wired modem and get our network name
@@ -78,10 +95,7 @@ for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
end
if not modem or not selfName then
print("[ERR] No wired modem found!")
print(" Attach a wired modem to the turtle")
print(" and connect it to the network.")
return
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)
@@ -91,15 +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
print("[ERR] Cannot wrap own peripheral: " .. selfName)
print(" Make sure the wired modem is connected.")
return
end
print("[OK] Self-inventory peripheral ready")
print("")
print("Waiting for craft commands from master...")
print("")
@@ -109,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
@@ -178,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))
@@ -266,20 +282,26 @@ end
-- Main loop: listen for modem commands
-------------------------------------------------
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
local ok, err = pcall(function()
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
if channel == CRAFT_CHANNEL and type(message) == "table" then
if message.type == "craft_request" then
local result = handleCraftCommand(message)
-- Send result back to master
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
elseif message.type == "ping" then
-- Health check from master
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
type = "pong",
name = selfName,
})
if channel == CRAFT_CHANNEL and type(message) == "table" then
if message.type == "craft_request" then
local result = handleCraftCommand(message)
-- Send result back to master
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
elseif message.type == "ping" then
-- Health check from master
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
type = "pong",
name = selfName,
})
end
end
end
end)
if not ok then
fatal("[ERR] Crafting turtle crashed:\n" .. tostring(err))
end

View File

@@ -1,10 +1,41 @@
-- Dropper Controller: Runs on Computer 1 (next to dropper_0)
-- Watches the dropper and pulses redstone until it's empty.
-- Dropper Controller
-- Watches a dropper peripheral and pulses redstone until it's empty.
local REDSTONE_SIDE = "back" -- side facing dropper_0
local DROPPER_SIDE = "back" -- side where the dropper peripheral is
local REDSTONE_SIDE = "back" -- side facing dropper (for redstone output)
local PULSE_TIME = 0.3 -- redstone pulse duration in seconds
local POLL_INTERVAL = 0.5 -- how often to check the dropper
local DROPPER_SIDE = "back" -- side where the dropper is (as peripheral)
-------------------------------------------------
-- Load config from file if present
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
-- 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")
local raw = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, raw)
if ok and cfg then
if cfg.dropperSide then DROPPER_SIDE = cfg.dropperSide end
if cfg.redstoneSide then REDSTONE_SIDE = cfg.redstoneSide end
if cfg.pulseTime then PULSE_TIME = cfg.pulseTime end
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
end
end
-------------------------------------------------
-- Helpers

View File

@@ -1,11 +1,11 @@
{
[ "im_inventory_manager" ] = {
title = "Inventory Manager",
title = "Inv Manager",
category = "Inventory",
run = "inventoryManager.lua",
},
[ "im_inventory_client" ] = {
title = "Inventory Display",
title = "Inv Display",
category = "Inventory",
run = "inventoryClient.lua",
},
@@ -13,21 +13,16 @@
title = "Crafting Turtle",
category = "Inventory",
run = "craftingTurtle.lua",
requires = { turtle = true },
},
[ "im_dropper_controller" ] = {
title = "Dropper Controller",
category = "Inventory",
run = "dropperController.lua",
requires = "turtle",
},
[ "im_web_bridge" ] = {
title = "Inventory Web Bridge",
title = "Inv Web Bridge",
category = "Inventory",
run = "inventoryWebBridge.lua",
},
[ "im_list_devices" ] = {
title = "List Devices",
[ "im_dropper" ] = {
title = "Dropper Ctrl",
category = "Inventory",
run = "listDevicesByType.lua",
run = "dropperController.lua",
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,37 @@
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")
if f then
f.write(tostring(err) .. "\n" .. (debug and debug.traceback and debug.traceback() or ""))
f.close()
end
end
local ok, err = xpcall(function()
-- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
local _ccDofile = dofile
local function dofile(path) -- luacheck: ignore
local fn, err = loadfile(path, nil, _ENV)
if fn then return fn()
else error(err, 2) end
end
-------------------------------------------------
-- Structured logging & shared UI helpers
-------------------------------------------------
@@ -22,7 +53,7 @@ local function _path(rel) return fs.combine(_baseDir, rel) 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)
@@ -117,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
@@ -163,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
@@ -291,6 +338,11 @@ local function main()
end
ops.refreshCache(drawBoot)
-- Destroy boot window so it doesn't intercept future monitor writes
buf.setVisible(true)
buf.clear()
buf = nil
else
ops.refreshCache()
end
@@ -392,7 +444,8 @@ local function main()
while true do
if state.needsRedraw then
state.needsRedraw = false
pcall(display.drawDashboard)
local dok, derr = pcall(display.drawDashboard)
if not dok then log.error("DRAW", "Dashboard: %s", tostring(derr)) end
end
if state.statusTimer > 0 then
state.statusTimer = state.statusTimer - 0.1
@@ -414,7 +467,8 @@ local function main()
while true do
if state.smelterNeedsRedraw then
state.smelterNeedsRedraw = false
pcall(display.drawSmelterDashboard)
local sok, serr = pcall(display.drawSmelterDashboard)
if not sok then log.error("DRAW", "Smelter: %s", tostring(serr)) end
end
sleep(0.1)
end
@@ -452,13 +506,31 @@ 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,
-- Task 12: Supply chest (builder / manifest-based stocking)
function()
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then return end
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then
while true do sleep(3600) end
end
log.info("SUPPLY", "Stocking %s with %d item types", cfg.SUPPLY_CHEST, #cfg.SUPPLY_MANIFEST)
while true do
pcall(ops.supplyChest)
@@ -468,13 +540,24 @@ local function main()
-- Task 13: Network order/command listener
function()
if not ctx.networkModem then return end
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()
@@ -634,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()
@@ -663,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
@@ -677,3 +816,15 @@ local function main()
end
main()
end, function(e) return tostring(e) .. "\n" .. debug.traceback() end)
if not ok then
_crashLog(err)
printError(tostring(err))
print("")
print("Crash log saved to: " .. _path(".crash.log"))
print("")
print("Press any key to exit...")
os.pullEvent("char")
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

@@ -40,6 +40,10 @@ local smelterPage = nil
local selectedAmount = 1
local amountOptions = {1, 4, 8, 16, 32, 64}
local searchQuery = ""
local showKeyboard = false
local orderPopupItem = nil
local orderPopupShort = nil
local orderPopupQty = 1
local smelterView = "status"
-------------------------------------------------
@@ -263,17 +267,46 @@ local function buildMainPage()
searchRow = UI.Window {
x = 1, y = 6, ex = -1, height = 1,
backgroundColor = colors.black,
},
searchEntry = UI.TextEntry {
x = 3, y = 6,
ex = '45%',
shadowText = "search...",
backgroundColor = colors.black,
backgroundFocusColor = colors.gray,
textColor = colors.white,
shadowTextColor = colors.gray,
limit = 30,
draw = function(self)
self:clear(colors.black)
-- Keyboard toggle button
local kbLabel = showKeyboard and " X " or " ? "
local kbBg = showKeyboard and colors.red or colors.purple
self:write(1, 1, kbLabel, kbBg, colors.white)
-- Search query display
local fieldW = math.floor(self.width * 0.4)
if fieldW < 10 then fieldW = 10 end
local queryDisplay = searchQuery
if showKeyboard then
queryDisplay = queryDisplay .. "|"
elseif queryDisplay == "" then
queryDisplay = "search..."
end
local displayText = queryDisplay:sub(1, fieldW)
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
local tc = (searchQuery == "" and not showKeyboard) and colors.gray or colors.white
self:write(5, 1, displayText, colors.black, tc)
end,
eventHandler = function(self, event)
if event.type == 'mouse_click' then
showKeyboard = not showKeyboard
local page = self.parent
if showKeyboard then
UI.Window.enable(page.keyboard)
page.keyboard:raise()
page.keyboard:draw()
else
page.keyboard:disable()
page.alertBar:draw()
page.footerBar:draw()
page.bottomBar:draw()
end
self:draw()
page:sync()
return true
end
return UI.Window.eventHandler(self, event)
end,
},
refreshBtn = UI.Button {
@@ -383,15 +416,229 @@ local function buildMainPage()
end,
},
-- On-screen keyboard overlay (bottom 3 rows; starts disabled)
keyboard = UI.Window {
x = 1, ex = -1, ey = -1, height = 3,
backgroundColor = colors.black,
enable = function() end, -- prevent auto-enable; toggled manually
draw = function(self)
self:clear(colors.black)
local kbDefs = {
{ keys = {"Q","W","E","R","T","Y","U","I","O","P"}, specials = {{ label = " Bksp ", bg = colors.red, action = "kb_bksp" }} },
{ keys = {"A","S","D","F","G","H","J","K","L"}, specials = {{ label = " Done ", bg = colors.green, action = "kb_done" }} },
{ keys = {"Z","X","C","V","B","N","M"}, specials = {
{ label = " Space ", bg = colors.lightGray, action = "kb_space" },
{ label = " Clr ", bg = colors.orange, action = "kb_clear" },
}},
}
self._zones = {}
local keyW = 3
local keyGap = 1
for rowIdx, def in ipairs(kbDefs) do
local y = rowIdx
local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap
local specialsW = 0
for _, sp in ipairs(def.specials) do
specialsW = specialsW + keyGap + #sp.label
end
local rowW = keysW + specialsW
local x = math.floor((self.width - rowW) / 2) + 1
-- Draw letter keys
for ki, key in ipairs(def.keys) do
self:write(x, y, " " .. key .. " ", colors.gray, colors.white)
table.insert(self._zones, { x1 = x, y1 = y, x2 = x + keyW - 1, y2 = y, action = "kb_key", data = key:lower() })
x = x + keyW
if ki < #def.keys then x = x + keyGap end
end
-- Draw special keys
for _, sp in ipairs(def.specials) do
x = x + keyGap
self:write(x, y, sp.label, sp.bg, colors.white)
table.insert(self._zones, { x1 = x, y1 = y, x2 = x + #sp.label - 1, y2 = y, action = sp.action })
x = x + #sp.label
end
end
end,
eventHandler = function(self, event)
if event.type == 'mouse_click' then
if self._zones then
for _, zone in ipairs(self._zones) do
if event.x >= zone.x1 and event.x <= zone.x2
and event.y >= zone.y1 and event.y <= zone.y2 then
self:emit({ type = zone.action, data = zone.data, element = self })
return true
end
end
end
return true -- consume click even if no zone hit
end
return UI.Window.eventHandler(self, event)
end,
},
-- Order quantity popup (full-screen overlay; starts disabled)
orderPopup = UI.Window {
x = 1, y = 1, ex = -1, ey = -1,
backgroundColor = colors.gray,
enable = function() end, -- toggled manually
draw = function(self)
self:clear(colors.gray)
self._zones = {}
local dw = math.min(self.width - 2, 30)
local dh = 8
local dx = math.floor((self.width - dw) / 2) + 1
local dy = math.floor((self.height - dh) / 2) + 1
-- Title row (blue background)
local title = "Order: " .. (orderPopupShort or "?")
if #title > dw - 2 then title = title:sub(1, dw - 2) end
self:write(dx, dy, string.rep(" ", dw), colors.blue)
local titleX = dx + math.floor((dw - #title) / 2)
self:write(titleX, dy, title, colors.blue, colors.white)
-- Dialog body rows (gray background)
for row = 1, dh - 1 do
self:write(dx, dy + row, string.rep(" ", dw), colors.gray)
end
-- Quantity display (row 2)
local qtyStr = string.format("Quantity: %d", orderPopupQty)
local qtyX = dx + math.floor((dw - #qtyStr) / 2)
self:write(qtyX, dy + 2, qtyStr, colors.gray, colors.white)
-- Increment buttons (row 4): [-8] [-1] [+1] [+8]
local incBtns = {
{ label = " -8 ", delta = -8, bg = colors.red },
{ label = " -1 ", delta = -1, bg = colors.red },
{ label = " +1 ", delta = 1, bg = colors.green },
{ label = " +8 ", delta = 8, bg = colors.green },
}
local totalIncW = 0
for _, b in ipairs(incBtns) do totalIncW = totalIncW + #b.label end
totalIncW = totalIncW + (#incBtns - 1) * 2
local incX = dx + math.floor((dw - totalIncW) / 2)
local incRow = dy + 4
for _, b in ipairs(incBtns) do
self:write(incX, incRow, b.label, b.bg, colors.white)
table.insert(self._zones, {
x1 = incX, y1 = incRow,
x2 = incX + #b.label - 1, y2 = incRow,
action = "order_delta", data = b.delta,
})
incX = incX + #b.label + 2
end
-- Preset buttons (row 5): [1] [4] [8] [16] [32] [64]
local presets = {1, 4, 8, 16, 32, 64}
local totalPreW = 0
for _, p in ipairs(presets) do totalPreW = totalPreW + #tostring(p) + 2 end
totalPreW = totalPreW + (#presets - 1)
local preX = dx + math.floor((dw - totalPreW) / 2)
local preRow = dy + 5
for _, p in ipairs(presets) do
local label = " " .. tostring(p) .. " "
local bg = (p == orderPopupQty) and colors.cyan or colors.lightGray
local fg = (p == orderPopupQty) and colors.white or colors.black
self:write(preX, preRow, label, bg, fg)
table.insert(self._zones, {
x1 = preX, y1 = preRow,
x2 = preX + #label - 1, y2 = preRow,
action = "order_set", data = p,
})
preX = preX + #label + 1
end
-- Action buttons (row 7): [Cancel] [Order]
local cancelLabel = " Cancel "
local orderLabel = " Order "
local actRow = dy + 7
local cancelX = dx + math.floor(dw / 4) - math.floor(#cancelLabel / 2)
local orderX = dx + math.floor(3 * dw / 4) - math.floor(#orderLabel / 2)
self:write(cancelX, actRow, cancelLabel, colors.red, colors.white)
table.insert(self._zones, {
x1 = cancelX, y1 = actRow,
x2 = cancelX + #cancelLabel - 1, y2 = actRow,
action = "order_cancel",
})
self:write(orderX, actRow, orderLabel, colors.lime, colors.white)
table.insert(self._zones, {
x1 = orderX, y1 = actRow,
x2 = orderX + #orderLabel - 1, y2 = actRow,
action = "order_confirm",
})
end,
eventHandler = function(self, event)
if event.type == 'mouse_click' then
if self._zones then
for _, zone in ipairs(self._zones) do
if event.x >= zone.x1 and event.x <= zone.x2
and event.y >= zone.y1 and event.y <= zone.y2 then
self:emit({ type = zone.action, data = zone.data, element = self })
return true
end
end
end
-- Click outside dialog dismisses popup
self:emit({ type = 'order_cancel', element = self })
return true
end
return UI.Window.eventHandler(self, event)
end,
},
-- Notification overlay
notification = UI.Notification {
anchor = 'bottom',
},
eventHandler = function(self, event)
if event.type == 'text_change' then
searchQuery = event.text or ""
if event.type == 'kb_key' then
if #searchQuery < 30 then
searchQuery = searchQuery .. event.data
end
D.refreshItemGrid()
self.searchRow:draw()
self.footerBar:draw()
self:sync()
return true
elseif event.type == 'kb_bksp' then
if #searchQuery > 0 then
searchQuery = searchQuery:sub(1, -2)
end
D.refreshItemGrid()
self.searchRow:draw()
self.footerBar:draw()
self:sync()
return true
elseif event.type == 'kb_space' then
if #searchQuery < 30 then
searchQuery = searchQuery .. " "
end
D.refreshItemGrid()
self.searchRow:draw()
self.footerBar:draw()
self:sync()
return true
elseif event.type == 'kb_done' then
showKeyboard = false
self.keyboard:disable()
self.searchRow:draw()
self.alertBar:draw()
self.footerBar:draw()
self.bottomBar:draw()
self:sync()
return true
elseif event.type == 'kb_clear' then
searchQuery = ""
showKeyboard = false
self.keyboard:disable()
D.refreshItemGrid()
self.searchRow:draw()
self.alertBar:draw()
self.footerBar:draw()
self.bottomBar:draw()
@@ -401,14 +648,55 @@ local function buildMainPage()
elseif event.type == 'grid_select' then
local row = event.selected
if row and row.name then
local short = shortName(row.name)
state.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
-- Hide keyboard if showing
if showKeyboard then
showKeyboard = false
self.keyboard:disable()
end
-- Show order popup
orderPopupItem = row.name
orderPopupShort = shortName(row.name)
orderPopupQty = selectedAmount
UI.Window.enable(self.orderPopup)
self.orderPopup:raise()
self.orderPopup:draw()
self:sync()
end
return true
elseif event.type == 'order_delta' then
orderPopupQty = math.max(1, math.min(999, orderPopupQty + event.data))
self.orderPopup:draw()
self:sync()
return true
elseif event.type == 'order_set' then
orderPopupQty = event.data
self.orderPopup:draw()
self:sync()
return true
elseif event.type == 'order_confirm' then
if orderPopupItem then
local short = shortName(orderPopupItem)
state.statusMessage = string.format("Ordering %s x%d...", short, orderPopupQty)
state.statusColor = colors.cyan
state.statusTimer = 10
activity.dispensing = true
state.needsRedraw = true
ops.orderItem(row.name, selectedAmount)
ops.orderItem(orderPopupItem, orderPopupQty)
end
self.orderPopup:disable()
orderPopupItem = nil
self:draw()
self:sync()
return true
elseif event.type == 'order_cancel' then
self.orderPopup:disable()
orderPopupItem = nil
self:draw()
self:sync()
return true
elseif event.type == 'amount_select' then
@@ -446,10 +734,13 @@ local function buildMainPage()
btnX = btnX + #tostring(amt) + 4
end
-- Attach to device
-- Attach to device (must resize to recompute all child dimensions
-- from the monitor device, since Page:postInit defaulted to UI.term)
mainDevice.currentPage = mainPage
mainPage.parent = mainDevice
mainPage:resize()
mainPage:setParent()
mainPage:enable()
end
function D.updateAmountButtons()
@@ -567,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,
@@ -667,8 +977,9 @@ local function buildSmelterPage()
turtleStatus = UI.Window {
x = -14, y = 0, width = 14, height = 1,
draw = function(self)
local turtleOk = ctx.craftTurtleName
and peripheral.isPresent(ctx.craftTurtleName)
local turtleOk = ctx.craftTurtleOk
or (ctx.craftTurtleName
and peripheral.isPresent(ctx.craftTurtleName))
local label = turtleOk and " Turtle OK " or " No Turtle "
local bg = turtleOk and colors.lime or colors.red
local fg = turtleOk and colors.black or colors.white
@@ -802,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()
@@ -849,9 +1150,22 @@ local function buildSmelterPage()
local recipeIdx = event.selected.idx
local recipe = cfg.CRAFTABLE[recipeIdx]
if recipe then
local turtleOk = ctx.craftTurtleName
and peripheral.isPresent(ctx.craftTurtleName)
-- 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
@@ -885,7 +1199,9 @@ local function buildSmelterPage()
-- Attach to device
smelterDevice.currentPage = smelterPage
smelterPage.parent = smelterDevice
smelterPage:resize()
smelterPage:setParent()
smelterPage:enable()
end
-------------------------------------------------
@@ -939,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

@@ -3,6 +3,13 @@
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
-- 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 = {
["inventoryClient.lua"] = "inventoryClient.lua",
@@ -65,8 +72,103 @@ else
print(string.format("All %d files up to date.", updated))
end
-------------------------------------------------
-- First-run setup
-------------------------------------------------
local VALID_SIDES = { "top", "bottom", "left", "right", "front", "back" }
local function isValidSide(s)
for _, v in ipairs(VALID_SIDES) do
if v == s then return true end
end
return false
end
local function firstRunSetup()
if fs.exists(SETUP_FILE) then return end
print("")
print("========== First-Run Setup ==========")
print("")
write("Is there a dropper next to this computer? (y/n): ")
local answer = read():lower():sub(1, 1)
if answer == "y" then
-- Ask which side the dropper is on
local side
while true do
print("")
print("Valid sides: top, bottom, left, right, front, back")
write("Which side is the dropper on? ")
side = read():lower():gsub("%s+", "")
if isValidSide(side) then
break
end
print("Invalid side. Please try again.")
end
-- Ask which side to pulse redstone from (defaults to same side)
print("")
write("Redstone output side [" .. side .. "]: ")
local rsSide = read():lower():gsub("%s+", "")
if rsSide == "" then rsSide = side end
if not isValidSide(rsSide) then
print("Invalid side, defaulting to " .. side)
rsSide = side
end
-- Save dropper config
local cfg = textutils.serialiseJSON({
dropperSide = side,
redstoneSide = rsSide,
enabled = true,
})
local f = fs.open(DROPPER_CONFIG, "w")
f.write(cfg)
f.close()
print("")
print("Dropper configured: peripheral=" .. side .. ", redstone=" .. rsSide)
else
-- No dropper - save config with enabled=false
local f = fs.open(DROPPER_CONFIG, "w")
f.write(textutils.serialiseJSON({ enabled = false }))
f.close()
print("No dropper configured.")
end
-- Mark setup as complete
local f = fs.open(SETUP_FILE, "w")
f.write("done")
f.close()
print("")
print("Setup complete!")
print("(Delete " .. SETUP_FILE .. " to re-run setup)")
sleep(2)
end
firstRunSetup()
-------------------------------------------------
-- Determine if dropper controller should run
-------------------------------------------------
local dropperEnabled = false
if fs.exists(DROPPER_CONFIG) then
local f = fs.open(DROPPER_CONFIG, "r")
local ok, cfg = pcall(textutils.unserialiseJSON, f.readAll())
f.close()
if ok and cfg and cfg.enabled then
dropperEnabled = true
end
end
print("")
print("Starting inventoryClient + dropperController...")
if dropperEnabled then
print("Starting inventoryClient + dropperController...")
else
print("Starting inventoryClient (no dropper)...")
end
sleep(1)
-- Reboot listener: reboots this computer on remote command
@@ -90,8 +192,12 @@ local function rebootListener()
end
end
parallel.waitForAny(
local tasks = {
function() shell.run("inventoryClient.lua") end,
function() shell.run("dropperController.lua") end,
rebootListener
)
rebootListener,
}
if dropperEnabled then
table.insert(tasks, function() shell.run("dropperController.lua") end)
end
parallel.waitForAny(table.unpack(tasks))

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:
@@ -27,7 +21,7 @@ services:
- inventory-network
depends_on:
server:
condition: service_started
condition: service_healthy
restart: unless-stopped
networks:

View File

@@ -1,8 +1,10 @@
# Node.js backend
FROM node:18-alpine
FROM node:20-alpine
# Build tools needed for better-sqlite3 native compilation
RUN apk add --no-cache python3 make g++
# su-exec for dropping privileges in entrypoint
# libstdc++ is kept at runtime (needed by better-sqlite3 native addon)
RUN apk add --no-cache python3 make g++ su-exec libstdc++
WORKDIR /app
@@ -11,20 +13,23 @@ 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 . .
# Create data directory for SQLite with proper ownership
RUN mkdir -p /data && chown node:node /data
# Create data directory for SQLite
RUN mkdir -p /data
VOLUME /data
# Run as non-root user for security
USER node
# Entrypoint fixes /data permissions then drops to node user
COPY docker-entrypoint.sh /usr/local/bin/
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(`

13
web/server/docker-entrypoint.sh Executable file
View File

@@ -0,0 +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 -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 "$@"