feat: ship enterprise control and desktop governance
This commit is contained in:
232
scripts/boss-state-store-maintenance.mjs
Executable file
232
scripts/boss-state-store-maintenance.mjs
Executable file
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
import { Client } from "pg";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const snapshotKey = process.env.BOSS_STATE_POSTGRES_KEY?.trim() || "default";
|
||||
const defaultStateFile = process.env.BOSS_STATE_FILE || path.join(process.cwd(), "data", "boss-state.json");
|
||||
const defaultBackupDir = process.env.BOSS_STATE_BACKUP_DIR || path.join(path.dirname(defaultStateFile), "backups");
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage: node scripts/boss-state-store-maintenance.mjs <command> [options]",
|
||||
"",
|
||||
"Commands:",
|
||||
" describe",
|
||||
" backup-file --input <file> [--output <file>] [--dry-run]",
|
||||
" export-file --input <file> --output <file> [--dry-run]",
|
||||
" migrate-file-to-postgres --input <file> [--dry-run]",
|
||||
" rollback-postgres-to-file --output <file> [--dry-run]",
|
||||
"",
|
||||
"Environment:",
|
||||
" BOSS_STATE_FILE, BOSS_DATABASE_URL, BOSS_STATE_POSTGRES_KEY, BOSS_STATE_BACKUP_DIR",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const [command, ...items] = argv;
|
||||
const options = { command, dryRun: false };
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
if (item === "--dry-run") {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (item === "--input") {
|
||||
options.input = items[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (item === "--output") {
|
||||
options.output = items[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`UNKNOWN_OPTION:${item}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function jsonOut(payload) {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function timestampSegment() {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function validateStateText(text, source) {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`STATE_JSON_INVALID:${source}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function readStateText(filePath) {
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
validateStateText(text, filePath);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function ensurePostgresSchema(client) {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS boss_state_snapshots (
|
||||
snapshot_key TEXT PRIMARY KEY,
|
||||
state JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function withPostgres(handler) {
|
||||
const connectionString = process.env.BOSS_DATABASE_URL?.trim();
|
||||
if (!connectionString) {
|
||||
throw new Error("BOSS_DATABASE_URL_REQUIRED");
|
||||
}
|
||||
const client = new Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
return await handler(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function backupFile(options) {
|
||||
const source = path.resolve(options.input || defaultStateFile);
|
||||
const text = await readStateText(source);
|
||||
const output = path.resolve(options.output || path.join(defaultBackupDir, `boss-state-${timestampSegment()}.json`));
|
||||
if (!options.dryRun) {
|
||||
await fs.mkdir(path.dirname(output), { recursive: true });
|
||||
await fs.writeFile(output, text, "utf8");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
action: "backup-file",
|
||||
dryRun: options.dryRun,
|
||||
source,
|
||||
output,
|
||||
bytes: Buffer.byteLength(text),
|
||||
};
|
||||
}
|
||||
|
||||
async function exportFile(options) {
|
||||
const source = path.resolve(options.input || defaultStateFile);
|
||||
if (!options.output) {
|
||||
throw new Error("OUTPUT_REQUIRED");
|
||||
}
|
||||
const text = await readStateText(source);
|
||||
const output = path.resolve(options.output);
|
||||
if (!options.dryRun) {
|
||||
await fs.mkdir(path.dirname(output), { recursive: true });
|
||||
await fs.writeFile(output, text, "utf8");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
action: "export-file",
|
||||
dryRun: options.dryRun,
|
||||
source,
|
||||
output,
|
||||
bytes: Buffer.byteLength(text),
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateFileToPostgres(options) {
|
||||
const source = path.resolve(options.input || defaultStateFile);
|
||||
const text = await readStateText(source);
|
||||
if (!options.dryRun) {
|
||||
await withPostgres(async (client) => {
|
||||
await ensurePostgresSchema(client);
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO boss_state_snapshots (snapshot_key, state, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (snapshot_key)
|
||||
DO UPDATE SET state = EXCLUDED.state, updated_at = now()
|
||||
`,
|
||||
[snapshotKey, text],
|
||||
);
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
action: "migrate-file-to-postgres",
|
||||
dryRun: options.dryRun,
|
||||
source,
|
||||
snapshotKey,
|
||||
bytes: Buffer.byteLength(text),
|
||||
};
|
||||
}
|
||||
|
||||
async function rollbackPostgresToFile(options) {
|
||||
const output = path.resolve(options.output || defaultStateFile);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
action: "rollback-postgres-to-file",
|
||||
dryRun: true,
|
||||
output,
|
||||
snapshotKey,
|
||||
};
|
||||
}
|
||||
const text = await withPostgres(async (client) => {
|
||||
await ensurePostgresSchema(client);
|
||||
const result = await client.query("SELECT state FROM boss_state_snapshots WHERE snapshot_key = $1", [snapshotKey]);
|
||||
const state = result.rows[0]?.state;
|
||||
if (!state) {
|
||||
throw new Error("BOSS_POSTGRES_STATE_NOT_FOUND");
|
||||
}
|
||||
return JSON.stringify(state, null, 2);
|
||||
});
|
||||
await fs.mkdir(path.dirname(output), { recursive: true });
|
||||
await fs.writeFile(output, text, "utf8");
|
||||
return {
|
||||
ok: true,
|
||||
action: "rollback-postgres-to-file",
|
||||
dryRun: false,
|
||||
output,
|
||||
snapshotKey,
|
||||
bytes: Buffer.byteLength(text),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
switch (options.command) {
|
||||
case "describe":
|
||||
jsonOut({
|
||||
ok: true,
|
||||
action: "describe",
|
||||
mode: process.env.BOSS_STATE_STORE === "postgres" ? "postgres" : "file",
|
||||
stateFile: path.resolve(defaultStateFile),
|
||||
backupDir: path.resolve(defaultBackupDir),
|
||||
postgresConfigured: Boolean(process.env.BOSS_DATABASE_URL?.trim()),
|
||||
postgresTable: "boss_state_snapshots",
|
||||
snapshotKey,
|
||||
});
|
||||
return;
|
||||
case "backup-file":
|
||||
jsonOut(await backupFile(options));
|
||||
return;
|
||||
case "export-file":
|
||||
jsonOut(await exportFile(options));
|
||||
return;
|
||||
case "migrate-file-to-postgres":
|
||||
jsonOut(await migrateFileToPostgres(options));
|
||||
return;
|
||||
case "rollback-postgres-to-file":
|
||||
jsonOut(await rollbackPostgresToFile(options));
|
||||
return;
|
||||
default:
|
||||
throw new Error(`UNKNOWN_COMMAND\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user