tools: private tools tracker (/trackers/tools): inventory of tools, batteries and consumables by category, +/- quantities, add/edit dialog, scripts/tools-run.sh for bots
Deploy production / deploy (push) Successful in 1m39s
Deploy production / deploy (push) Successful in 1m39s
This commit is contained in:
1 parent
42711253b7
commit
7f71e88953
18 files changed
+47392
-3
No files matched your search
@@ -368,6 +368,28 @@ try {
|
||||
throw new Error(`radio schema tables/grants are wrong: ${JSON.stringify(radio)}`);
|
||||
const [{ n: radioFreqs }] = await client`select count(*)::int as n from radio.frequencies`;
|
||||
if (!radioFreqs) throw new Error('radio.frequencies is empty, expected the seeded frequencies');
|
||||
// Tools tracker (owner-only): 1 = items; the migration seeds Stan's first 5 items.
|
||||
const [tools] = await client`
|
||||
select
|
||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||
count(*) filter (
|
||||
where exists (
|
||||
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
||||
where r.rolname = 'personal_ro'
|
||||
)
|
||||
) as ro_read,
|
||||
count(*) as tables,
|
||||
has_schema_privilege('public', 'tools', 'usage') as public_usage
|
||||
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'tools' and c.relkind = 'r'
|
||||
`;
|
||||
if (
|
||||
Number(tools.tables) !== 1 ||
|
||||
Number(tools.app_rw) !== 1 ||
|
||||
Number(tools.ro_read) !== 0 ||
|
||||
tools.public_usage
|
||||
)
|
||||
throw new Error(`tools schema tables/grants are wrong: ${JSON.stringify(tools)}`);
|
||||
// Energy tracker reads the ned-collector tables (SELECT only).
|
||||
const [energy] = await client`
|
||||
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
||||
@@ -377,7 +399,7 @@ try {
|
||||
if (!Number(energy.hours))
|
||||
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
||||
console.log(
|
||||
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), radio: ${radio.tables} tables (${radioFreqs} frequencies), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), radio: ${radio.tables} tables (${radioFreqs} frequencies), tools: ${tools.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||
);
|
||||
const [catalog] = await client`
|
||||
select
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run scripts/tools.ts on stan-ovh against prod, without printing secrets.
|
||||
# scripts/tools-run.sh list | import <file.json> | add '<json>' | delete <id> (see scripts/tools.ts)
|
||||
# DB_URL (app role) comes from the running app container (db:5432 → 127.0.0.1:5440).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
DB_URL=$(sudo -n docker inspect personal-website-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||
| sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
|
||||
[ -n "$DB_URL" ] || { echo "no DB_URL from the app container" >&2; exit 1; }
|
||||
export DB_URL
|
||||
exec bun scripts/tools.ts "$@"
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tools tracker (/trackers/tools, tools.items) from the command line, for bots and Stan.
|
||||
* Items are keyed by name (case-insensitive) or id; importing again only updates the fields
|
||||
* given, merges specs (a null spec value removes that key) and never duplicates an item.
|
||||
*
|
||||
* On stan-ovh (DB_URL is loaded from the app container, nothing printed):
|
||||
* scripts/tools-run.sh list [--json]
|
||||
* scripts/tools-run.sh import ~/imports/tools.json [--dry-run]
|
||||
* scripts/tools-run.sh add '{"name":"AA batteries","category":"batteries","quantity":24}'
|
||||
* scripts/tools-run.sh add '{"name":"AAA batteries","add":-4}' # used 4
|
||||
* scripts/tools-run.sh delete 12 # by id
|
||||
*
|
||||
* Entry fields (only name required): name, brand, model, category, quantity | add (delta),
|
||||
* location, purchased_on (YYYY-MM-DD), price_eur, specs (object of string|number), notes, id.
|
||||
* Categories: batteries, power_tools, hand_tools, measuring, electrical, fasteners, consumables,
|
||||
* safety, other (see src/lib/tools/index.ts; "power tools" etc. are understood too).
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import postgres from 'postgres';
|
||||
import { importItems, listItems, parseImport } from '../src/lib/server/tools/store';
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
const dry = rest.includes('--dry-run');
|
||||
const args = rest.filter((a) => !a.startsWith('--'));
|
||||
const usage =
|
||||
'usage: tools.ts list [--json] | import <file.json> [--dry-run] | add <json> [--dry-run] | delete <id>';
|
||||
if (!cmd) throw new Error(usage);
|
||||
|
||||
let input: unknown;
|
||||
if (cmd === 'import') {
|
||||
if (!args[0]) throw new Error(usage);
|
||||
input = JSON.parse(await readFile(args[0], 'utf8'));
|
||||
} else if (cmd === 'add') {
|
||||
if (!args[0]) throw new Error(usage);
|
||||
input = JSON.parse(args.join(' '));
|
||||
} else if (!['list', 'delete'].includes(cmd)) throw new Error(usage);
|
||||
|
||||
const parsed = input === undefined ? null : parseImport(input);
|
||||
if (parsed) {
|
||||
console.log(`parsed ${parsed.rows.length} entries`);
|
||||
if (parsed.errors.length) {
|
||||
for (const e of parsed.errors.slice(0, 50)) console.error(` ${e}`);
|
||||
console.error(`${parsed.errors.length} invalid entries; nothing imported`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (dry) process.exit(0);
|
||||
}
|
||||
|
||||
if (!process.env.DB_URL)
|
||||
throw new Error('DB_URL is required (use scripts/tools-run.sh on stan-ovh)');
|
||||
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
if (cmd === 'list') {
|
||||
const items = await listItems(sql);
|
||||
if (rest.includes('--json')) console.log(JSON.stringify(items, null, 2));
|
||||
else
|
||||
for (const it of items)
|
||||
console.log(
|
||||
`${String(it.id).padStart(4)} ${it.category.padEnd(12)} x${String(it.quantity).padEnd(4)} ${it.name}${
|
||||
it.brand || it.model ? ` (${[it.brand, it.model].filter(Boolean).join(' ')})` : ''
|
||||
}`
|
||||
);
|
||||
} else if (cmd === 'delete') {
|
||||
const id = Number(args[0]);
|
||||
if (!Number.isInteger(id) || id <= 0) throw new Error(usage);
|
||||
const r = await sql`DELETE FROM tools.items WHERE id = ${id} RETURNING name`;
|
||||
console.log(r.length ? `deleted ${id} ${r[0].name}` : `no item ${id}`);
|
||||
} else {
|
||||
const r = await importItems(sql, parsed!.rows);
|
||||
console.log(`imported ${r.total}: ${r.created} new, ${r.updated} updated`);
|
||||
if (r.missing.length) console.error(`not found: ${r.missing.join(', ')}`);
|
||||
}
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
Reference in new issue
Block a user