DSH-Arch-Setup/plugins/dsh-project-tree/lib/index.js
Шурупов Илья Викторович cf9443753e Add one-command Arch DSH setup
Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
2026-09-12 19:13:36 +03:00

406 lines
18 KiB
JavaScript

import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { isExactIdSet, nextSortIndex, parseName } from "./tree-utils.js";
const name = "dsh-project-tree";
const inject = ["webServer", "storageDomain", "workspaceRegistry"];
const ROUTE_PREFIX = "/dsh-project-tree";
const MAX_BODY_BYTES = 65536;
const SESSION_ID_RE = /^(session-)?[0-9a-fA-F-]+$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const projectSchema = z.object({
id: z.string(),
name: z.string(),
sortIndex: z.number().int().min(0).optional()
});
const groupSchema = z.object({
id: z.string(),
projectId: z.string().optional(),
workspaceId: z.string().optional(),
name: z.string(),
sortIndex: z.number().int().min(0).optional()
});
const taskSchema = z.object({
id: z.string(),
groupId: z.string(),
name: z.string(),
sessionIds: z.array(z.string()),
sortIndex: z.number().int().min(0).optional()
});
const treeDomainSpec = defineDomain({
name: "dsh_project_tree",
version: 1,
global: {
schema: z.object({
projects: z.array(projectSchema).optional(),
groups: z.array(groupSchema),
tasks: z.array(taskSchema),
inWork: z.array(z.string()).optional()
}),
initial: { projects: [], groups: [], tasks: [], inWork: [] }
},
tables: {}
});
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", (chunk) => {
data += chunk;
if (data.length > MAX_BODY_BYTES) {
req.destroy();
const err = new Error("request body too large");
err.clientError = true;
reject(err);
}
});
req.on("end", () => {
if (data.length === 0) return resolve({});
try {
resolve(JSON.parse(data));
} catch {
const err = new Error("invalid JSON body");
err.clientError = true;
reject(err);
}
});
req.on("error", reject);
});
}
function respond(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body)
});
res.end(body);
}
function normalize(record) {
return {
projects: Array.isArray(record?.projects) ? record.projects : [],
groups: Array.isArray(record?.groups) ? record.groups : [],
tasks: Array.isArray(record?.tasks) ? record.tasks : [],
inWork: Array.isArray(record?.inWork) ? record.inWork : []
};
}
function migrate(ctx, record) {
const next = normalize(record);
const workspaceToProject = new Map();
let changed = !Array.isArray(record?.projects);
for (const group of next.groups) {
if (typeof group.projectId === "string" && group.projectId.length > 0) continue;
const workspaceId = group.workspaceId ?? "__legacy__";
let projectId = workspaceToProject.get(workspaceId);
if (projectId === undefined) {
const workspace = ctx.workspaceRegistry.list().find((candidate) => candidate.id === workspaceId);
projectId = randomUUID();
next.projects.push({ id: projectId, name: workspace?.title ?? "Imported", sortIndex: nextSortIndex(next.projects) });
workspaceToProject.set(workspaceId, projectId);
}
group.projectId = projectId;
delete group.workspaceId;
changed = true;
}
return { record: next, changed };
}
function apply(ctx) {
return ctx.storageDomain.open(treeDomainSpec).then(async (treeDomain) => {
const migrated = migrate(ctx, treeDomain.global.get());
if (migrated.changed) await treeDomain.global.set(migrated.record);
const readRecord = () => normalize(treeDomain.global.get());
const writeRecord = (record) => treeDomain.global.set(normalize(record));
let mutationTail = Promise.resolve();
const withMutationLock = (operation) => {
const result = mutationTail.then(operation, operation);
mutationTail = result.then(() => void 0, () => void 0);
return result;
};
const ws = ctx.webServer;
const route = (path, handler) => {
ws.register({
kind: "exact",
path: ROUTE_PREFIX + "/" + path,
handler: async (req, res) => {
if (req.method !== "POST") return respond(res, 405, { error: "method-not-allowed" });
const contentType = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
if (contentType !== "application/json") return respond(res, 400, { error: "bad-request" });
if (req.headers["sec-fetch-site"] === "cross-site") return respond(res, 403, { error: "cross-origin-denied" });
const origin = req.headers["origin"];
if (typeof origin === "string" && origin.length > 0) {
try {
if (new URL(origin).host !== req.headers.host) return respond(res, 403, { error: "cross-origin-denied" });
} catch {
return respond(res, 403, { error: "cross-origin-denied" });
}
}
try {
const body = await readJsonBody(req);
await handler(body, res);
} catch (error) {
if (error.clientError === true) return respond(res, 400, { error: "bad-request" });
ctx.logger.warn("[dsh-project-tree] route failed:", error);
respond(res, 500, { error: "internal-error" });
}
}
});
};
route("list", async (_body, res) => {
const record = readRecord();
const aliveProjects = new Set(record.projects.map((project) => project.id));
const groups = record.groups.filter((group) => aliveProjects.has(group.projectId));
const aliveGroups = new Set(groups.map((group) => group.id));
const tasks = record.tasks.filter((task) => aliveGroups.has(task.groupId));
const workspaces = ctx.workspaceRegistry.list().map((workspace) => ({
id: workspace.id,
title: workspace.title,
path: workspace.path
}));
respond(res, 200, { projects: record.projects, groups, tasks, workspaces, inWork: record.inWork });
});
route("in-work", async (body, res) => {
const id = body?.id;
const value = body?.value;
if (typeof id !== "string" || !SESSION_ID_RE.test(id) || typeof value !== "boolean") return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const has = record.inWork.includes(id);
if (value === has) return respond(res, 200, { ok: true });
const inWork = value ? [...record.inWork, id] : record.inWork.filter((candidate) => candidate !== id);
await writeRecord({ ...record, inWork });
respond(res, 200, { ok: true });
});
});
route("project-create", async (body, res) => {
const projectName = parseName(body?.name);
if (projectName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
let id;
do { id = randomUUID(); } while (record.projects.some((project) => project.id === id));
await writeRecord({ ...record, projects: [...record.projects, { id, name: projectName, sortIndex: nextSortIndex(record.projects) }] });
respond(res, 200, { id });
});
});
route("project-rename", async (body, res) => {
const projectId = body?.projectId;
const projectName = parseName(body?.name);
if (typeof projectId !== "string" || projectId.length === 0 || projectName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" });
await writeRecord({ ...record, projects: record.projects.map((project) => project.id === projectId ? { ...project, name: projectName } : project) });
respond(res, 200, { ok: true });
});
});
route("project-delete", async (body, res) => {
const projectId = body?.projectId;
if (typeof projectId !== "string" || projectId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const doomedGroups = new Set(record.groups.filter((group) => group.projectId === projectId).map((group) => group.id));
const doomedTasks = new Set(record.tasks.filter((task) => doomedGroups.has(task.groupId)).map((task) => task.id));
const doomed = new Set([projectId, ...doomedGroups, ...doomedTasks]);
await writeRecord({
projects: record.projects.filter((project) => project.id !== projectId),
groups: record.groups.filter((group) => group.projectId !== projectId),
tasks: record.tasks.filter((task) => !doomedGroups.has(task.groupId)),
inWork: record.inWork.filter((id) => !doomed.has(id))
});
respond(res, 200, { ok: true });
});
});
route("projects-reorder", async (body, res) => {
const orderedIds = body?.orderedIds;
if (!Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!isExactIdSet(orderedIds, new Set(record.projects.map((project) => project.id)))) return respond(res, 400, { error: "bad-request" });
const indexOf = new Map(orderedIds.map((id, index) => [id, index]));
await writeRecord({ ...record, projects: record.projects.map((project) => ({ ...project, sortIndex: indexOf.get(project.id) })) });
respond(res, 200, { ok: true });
});
});
route("group-create", async (body, res) => {
const projectId = body?.projectId;
const groupName = parseName(body?.name);
if (typeof projectId !== "string" || projectId.length === 0 || groupName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" });
const siblings = record.groups.filter((group) => group.projectId === projectId);
let id;
do { id = randomUUID(); } while (record.groups.some((group) => group.id === id));
await writeRecord({ ...record, groups: [...record.groups, { id, projectId, name: groupName, sortIndex: nextSortIndex(siblings) }] });
respond(res, 200, { id });
});
});
route("group-rename", async (body, res) => {
const groupId = body?.groupId;
const groupName = parseName(body?.name);
if (typeof groupId !== "string" || groupId.length === 0 || groupName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
await writeRecord({ ...record, groups: record.groups.map((group) => group.id === groupId ? { ...group, name: groupName } : group) });
respond(res, 200, { ok: true });
});
});
route("group-delete", async (body, res) => {
const groupId = body?.groupId;
if (typeof groupId !== "string" || groupId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
const doomedTasks = new Set(record.tasks.filter((task) => task.groupId === groupId).map((task) => task.id));
const doomed = new Set([groupId, ...doomedTasks]);
await writeRecord({
...record,
groups: record.groups.filter((group) => group.id !== groupId),
tasks: record.tasks.filter((task) => task.groupId !== groupId),
inWork: record.inWork.filter((id) => !doomed.has(id))
});
respond(res, 200, { ok: true });
});
});
route("group-move", async (body, res) => {
const groupId = body?.groupId;
const projectId = body?.projectId;
if (typeof groupId !== "string" || typeof projectId !== "string" || groupId.length === 0 || projectId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" });
const targetSiblings = record.groups.filter((group) => group.projectId === projectId && group.id !== groupId);
await writeRecord({ ...record, groups: record.groups.map((group) => group.id === groupId ? { ...group, projectId, sortIndex: nextSortIndex(targetSiblings) } : group) });
respond(res, 200, { ok: true });
});
});
route("groups-reorder", async (body, res) => {
const projectId = body?.projectId;
const orderedIds = body?.orderedIds;
if (typeof projectId !== "string" || projectId.length === 0 || !Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const ownedIds = new Set(record.groups.filter((group) => group.projectId === projectId).map((group) => group.id));
if (!isExactIdSet(orderedIds, ownedIds)) return respond(res, 400, { error: "bad-request" });
const indexOf = new Map(orderedIds.map((id, index) => [id, index]));
await writeRecord({ ...record, groups: record.groups.map((group) => group.projectId === projectId ? { ...group, sortIndex: indexOf.get(group.id) } : group) });
respond(res, 200, { ok: true });
});
});
route("task-create", async (body, res) => {
const groupId = body?.groupId;
const taskName = parseName(body?.name);
if (typeof groupId !== "string" || groupId.length === 0 || taskName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
const siblings = record.tasks.filter((task) => task.groupId === groupId);
let id;
do { id = randomUUID(); } while (record.tasks.some((task) => task.id === id));
await writeRecord({ ...record, tasks: [...record.tasks, { id, groupId, name: taskName, sessionIds: [], sortIndex: nextSortIndex(siblings) }] });
respond(res, 200, { id });
});
});
route("task-rename", async (body, res) => {
const taskId = body?.taskId;
const taskName = parseName(body?.name);
if (typeof taskId !== "string" || taskId.length === 0 || taskName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" });
await writeRecord({ ...record, tasks: record.tasks.map((task) => task.id === taskId ? { ...task, name: taskName } : task) });
respond(res, 200, { ok: true });
});
});
route("task-delete", async (body, res) => {
const taskId = body?.taskId;
if (typeof taskId !== "string" || taskId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" });
await writeRecord({ ...record, tasks: record.tasks.filter((task) => task.id !== taskId), inWork: record.inWork.filter((id) => id !== taskId) });
respond(res, 200, { ok: true });
});
});
route("task-move", async (body, res) => {
const taskId = body?.taskId;
const groupId = body?.groupId;
if (typeof taskId !== "string" || typeof groupId !== "string" || taskId.length === 0 || groupId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" });
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
const targetSiblings = record.tasks.filter((task) => task.groupId === groupId && task.id !== taskId);
await writeRecord({ ...record, tasks: record.tasks.map((task) => task.id === taskId ? { ...task, groupId, sortIndex: nextSortIndex(targetSiblings) } : task) });
respond(res, 200, { ok: true });
});
});
route("tasks-reorder", async (body, res) => {
const groupId = body?.groupId;
const orderedIds = body?.orderedIds;
if (typeof groupId !== "string" || groupId.length === 0 || !Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const ownedIds = new Set(record.tasks.filter((task) => task.groupId === groupId).map((task) => task.id));
if (!isExactIdSet(orderedIds, ownedIds)) return respond(res, 400, { error: "bad-request" });
const indexOf = new Map(orderedIds.map((id, index) => [id, index]));
await writeRecord({ ...record, tasks: record.tasks.map((task) => task.groupId === groupId ? { ...task, sortIndex: indexOf.get(task.id) } : task) });
respond(res, 200, { ok: true });
});
});
route("session-assign", async (body, res) => {
const sessionId = body?.sessionId;
const taskId = body?.taskId;
if (typeof sessionId !== "string" || !SESSION_ID_RE.test(sessionId)) return respond(res, 400, { error: "bad-request" });
if (taskId !== null && (typeof taskId !== "string" || !UUID_RE.test(taskId))) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const without = record.tasks.map((task) => ({ ...task, sessionIds: task.sessionIds.filter((id) => id !== sessionId) }));
if (taskId === null) {
await writeRecord({ ...record, tasks: without });
return respond(res, 200, { ok: true });
}
if (!without.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" });
await writeRecord({ ...record, tasks: without.map((task) => task.id === taskId ? { ...task, sessionIds: [sessionId, ...task.sessionIds] } : task) });
respond(res, 200, { ok: true });
});
});
return () => treeDomain.close();
});
}
export { apply, inject, name };