From b236dccca3048ec6db0fcb53f9de4bcabaafaae2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 08:58:33 +0000 Subject: [PATCH] refactor: extract shared normalizeProjectScripts into @t3tools/contracts Move the duplicated script normalization logic (dedup-by-id + single runOnWorktreeCreate enforcement) from apps/web/src/store.ts and apps/server/src/projectRegistry.ts into packages/contracts/src/project.ts so both consumers share one canonical implementation. Applied via @cursor push command --- apps/server/src/projectRegistry.ts | 37 +++--------------------------- apps/web/src/store.ts | 33 +------------------------- packages/contracts/src/project.ts | 37 ++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 66 deletions(-) diff --git a/apps/server/src/projectRegistry.ts b/apps/server/src/projectRegistry.ts index b6b56cbe35838183adf3a369c4fc21d74d14cfc2..c849192d53ec4064425467c67416dc94f0c9902f 100644 --- a/apps/server/src/projectRegistry.ts +++ b/apps/server/src/projectRegistry.ts @@ -11,6 +11,7 @@ import { type ProjectScript, type ProjectUpdateScriptsInput, type ProjectUpdateScriptsResult, + normalizeProjectScripts, projectAddInputSchema, projectRecordSchema, projectRemoveInputSchema, @@ -130,7 +131,7 @@ export class ProjectRegistry { throw new Error(`Project not found: ${input.id}`); } - const nextScripts = dedupeScriptsById(projectScriptsSchema.parse(input.scripts)); + const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); const nextUpdatedAt = new Date().toISOString(); const nextProject: ProjectRecord = { ...this.projects[index]!, @@ -167,7 +168,7 @@ export class ProjectRegistry { cwd: normalizedCwd, name: project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), - scripts: dedupeScriptsById(project.scripts), + scripts: normalizeProjectScripts(project.scripts), }; deduped.set(normalizedCwd, normalizedProject); normalizedProjects.push(normalizedProject); @@ -232,35 +233,3 @@ export class ProjectRegistry { fs.renameSync(tempFile, this.filePath); } } - -function dedupeScriptsById(scripts: readonly ProjectScript[]): ProjectScript[] { - const seenIds = new Set(); - const deduped: ProjectScript[] = []; - for (const script of scripts) { - if (seenIds.has(script.id)) continue; - seenIds.add(script.id); - deduped.push({ - id: script.id, - name: script.name, - command: script.command, - icon: script.icon, - runOnWorktreeCreate: script.runOnWorktreeCreate, - }); - } - - let setupAssigned = false; - const normalized: ProjectScript[] = []; - for (const script of deduped) { - if (!script.runOnWorktreeCreate) { - normalized.push(script); - continue; - } - if (!setupAssigned) { - setupAssigned = true; - normalized.push(script); - continue; - } - normalized.push({ ...script, runOnWorktreeCreate: false }); - } - return normalized; -} diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 1e790fbea8674f830185c73a5ba0c80ce632ea5b..b45e36e17e68e78f8e3090655494c3e85242d3a7 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -8,7 +8,7 @@ import { useReducer, } from "react"; -import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; +import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; import { resolveModelSlug } from "./model-logic"; import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; @@ -165,37 +165,6 @@ function normalizeRunningTerminalIds( .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); } -function normalizeProjectScripts(scripts: ProjectScript[]): ProjectScript[] { - const deduped: ProjectScript[] = []; - const seenIds = new Set(); - for (const script of scripts) { - const id = script.id.trim(); - if (id.length === 0 || seenIds.has(id)) continue; - seenIds.add(id); - deduped.push({ - ...script, - id, - name: script.name.trim(), - command: script.command.trim(), - }); - } - - let setupAssigned = false; - const normalized: ProjectScript[] = []; - for (const script of deduped) { - if (!script.runOnWorktreeCreate) { - normalized.push(script); - continue; - } - if (!setupAssigned) { - setupAssigned = true; - normalized.push(script); - continue; - } - normalized.push({ ...script, runOnWorktreeCreate: false }); - } - return normalized; -} function normalizeTerminalGroupIds(terminalIds: string[]): string[] { return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0466a6887ed836a2a0de3c4cf88e8d3ad504ee9e..ae04f1240bcb45a77064b77e63c078019b7da5b4 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -84,6 +84,43 @@ export const projectUpdateScriptsResultSchema = z.object({ export type ProjectScriptIcon = z.infer; export type ProjectScript = z.infer; + +/** + * Deduplicate scripts by ID and enforce at most one `runOnWorktreeCreate: true` + * flag (the first one wins). Fields are trimmed defensively so this is safe to + * call on data that hasn't been through Zod parsing. + */ +export function normalizeProjectScripts(scripts: readonly ProjectScript[]): ProjectScript[] { + const seenIds = new Set(); + const deduped: ProjectScript[] = []; + for (const script of scripts) { + const id = script.id.trim(); + if (id.length === 0 || seenIds.has(id)) continue; + seenIds.add(id); + deduped.push({ + ...script, + id, + name: script.name.trim(), + command: script.command.trim(), + }); + } + + let setupAssigned = false; + const normalized: ProjectScript[] = []; + for (const script of deduped) { + if (!script.runOnWorktreeCreate) { + normalized.push(script); + continue; + } + if (!setupAssigned) { + setupAssigned = true; + normalized.push(script); + continue; + } + normalized.push({ ...script, runOnWorktreeCreate: false }); + } + return normalized; +} export type ProjectRecord = z.infer; export type ProjectListResult = z.infer; export type ProjectAddInput = z.input; -- 2.54.0