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.
406 lines
18 KiB
JavaScript
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 };
|