mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 13:10:44 +08:00
107 lines
3.7 KiB
JavaScript
107 lines
3.7 KiB
JavaScript
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
import { dirname, isAbsolute } from 'node:path';
|
|
|
|
import { normalizeOfficeBaseUrl, OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
|
|
|
|
const ALIAS = /^[a-z][a-z0-9-]{1,63}$/;
|
|
const DEVICE_ID = /^[a-z0-9][a-z0-9_-]{2,63}$/i;
|
|
|
|
function cleanString(value) {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function normalizeMap(value, { kind }) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
const output = {};
|
|
for (const [rawId, rawValue] of Object.entries(value)) {
|
|
const id = cleanString(rawId);
|
|
const item = cleanString(rawValue);
|
|
if (!id || !ALIAS.test(id) || !item) return null;
|
|
if (kind === 'workspace' && !isAbsolute(item)) return null;
|
|
if (kind === 'preset' && item.length > 8_000) return null;
|
|
output[id] = item;
|
|
}
|
|
return Object.freeze(output);
|
|
}
|
|
|
|
export function normalizeOfficeConfig(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
let baseUrl;
|
|
try {
|
|
baseUrl = normalizeOfficeBaseUrl(value.baseUrl).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
const deviceId = cleanString(value.deviceId);
|
|
const deviceTokenRef = cleanString(value.deviceTokenRef);
|
|
const workspaces = normalizeMap(value.workspaces ?? {}, { kind: 'workspace' });
|
|
const instructionPresets = normalizeMap(value.instructionPresets ?? {}, { kind: 'preset' });
|
|
const maxConcurrency = Number(value.maxConcurrency ?? 1);
|
|
const heartbeatSeconds = Number(value.heartbeatSeconds ?? 30);
|
|
if (!deviceId || !DEVICE_ID.test(deviceId) || !deviceTokenRef
|
|
|| !/^DSH_OFFICE_DEVICE_TOKEN_[A-F0-9]{24}$/.test(deviceTokenRef)
|
|
|| !workspaces || !instructionPresets
|
|
|| !Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4
|
|
|| !Number.isInteger(heartbeatSeconds) || heartbeatSeconds < 10 || heartbeatSeconds > 300) {
|
|
return null;
|
|
}
|
|
return Object.freeze({
|
|
version: 1,
|
|
protocolVersion: OFFICE_PROTOCOL_VERSION,
|
|
baseUrl,
|
|
deviceId,
|
|
deviceTokenRef,
|
|
maxConcurrency,
|
|
heartbeatSeconds,
|
|
workspaces,
|
|
instructionPresets,
|
|
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
|
updatedAt: cleanString(value.updatedAt) ?? new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
export class OfficeConfigStore {
|
|
#path;
|
|
#value = null;
|
|
#queue = Promise.resolve();
|
|
|
|
constructor(path) { this.#path = path; }
|
|
|
|
async load() {
|
|
try {
|
|
const normalized = normalizeOfficeConfig(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
if (!normalized) throw new Error('dsh-im AI Office config is invalid');
|
|
this.#value = normalized;
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
this.#value = null;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
get() { return this.#value ? structuredClone(this.#value) : null; }
|
|
|
|
async save(value) {
|
|
const normalized = normalizeOfficeConfig(value);
|
|
if (!normalized) throw new Error('Refusing to persist invalid AI Office configuration');
|
|
const operation = this.#queue.then(async () => {
|
|
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
const temporary = `${this.#path}.tmp`;
|
|
await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
|
|
await rename(temporary, this.#path);
|
|
this.#value = normalized;
|
|
});
|
|
this.#queue = operation.then(() => undefined, () => undefined);
|
|
await operation;
|
|
return this.get();
|
|
}
|
|
|
|
async clear() {
|
|
const operation = this.#queue.then(async () => {
|
|
try { await unlink(this.#path); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
this.#value = null;
|
|
});
|
|
this.#queue = operation.then(() => undefined, () => undefined);
|
|
await operation;
|
|
}
|
|
}
|