1import {
2 MAX_SCRIPT_ID_LENGTH,
3 SCRIPT_RUN_COMMAND_PATTERN,
4 type KeybindingCommand,
5 type ProjectScript,
6} from "@t3tools/contracts";
7import * as Schema from "effect/Schema";
8const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN);
9
10export interface ProjectScriptInput {
11 readonly name: ProjectScript["name"];
12 readonly command: ProjectScript["command"];
13 readonly icon: ProjectScript["icon"];
14 readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];
15 readonly waitForSetup: boolean;
16 readonly runOnSettle: boolean;
17 readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null;
18 readonly autoOpenPreview: boolean;
19}
20
21export function buildProjectScript(id: string, input: ProjectScriptInput): ProjectScript {
22 return {
23 id,
24 name: input.name,
25 command: input.command,
26 icon: input.icon,
27 runOnWorktreeCreate: input.runOnWorktreeCreate,
28 ...(input.runOnWorktreeCreate && input.waitForSetup ? { async: false } : {}),
29 ...(input.runOnSettle ? { runOnSettle: true } : {}),
30 ...(input.previewUrl === null
31 ? {}
32 : {
33 previewUrl: input.previewUrl,
34 autoOpenPreview: input.autoOpenPreview,
35 }),
36 };
37}
38
39/**
40 * A project runs at most one setup script and one settle script, so saving a
41 * script that claims either role takes it from the script that held it.
42 */
43export function releaseClaimedRoles(
44 script: ProjectScript,
45 saved: ProjectScriptInput,
46): ProjectScript {
47 const releaseSetup = saved.runOnWorktreeCreate && script.runOnWorktreeCreate;
48 const releaseSettle = saved.runOnSettle && script.runOnSettle === true;
49 if (!releaseSetup && !releaseSettle) return script;
50 return {
51 ...script,
52 ...(releaseSetup ? { runOnWorktreeCreate: false } : {}),
53 ...(releaseSettle ? { runOnSettle: false } : {}),
54 };
55}
56
57function normalizeScriptId(value: string): string {
58 const cleaned = value
59 .trim()
60 .toLowerCase()
61 .replace(/[^a-z0-9]+/g, "-")
62 .replace(/^-+|-+$/g, "");
63 if (cleaned.length === 0) {
64 return "script";
65 }
66 if (cleaned.length <= MAX_SCRIPT_ID_LENGTH) {
67 return cleaned;
68 }
69 return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script";
70}
71
72/** Legacy script IDs may not support shortcuts; keep those scripts usable without one. */
73export function commandForProjectScript(scriptId: string): KeybindingCommand | null {
74 const command = `script.${scriptId}.run`;
75 return isScriptRunCommand(command) ? command : null;
76}
77
78export function projectScriptIdFromCommand(command: string): string | null {
79 const trimmed = command.trim();
80 if (!isScriptRunCommand(trimmed)) {
81 return null;
82 }
83 const [prefix, , suffix] = SCRIPT_RUN_COMMAND_PATTERN.parts;
84 return trimmed.slice(prefix.literal.length, -suffix.literal.length);
85}
86
87export function nextProjectScriptId(name: string, existingIds: Iterable<string>): string {
88 const taken = new Set(Array.from(existingIds));
89 const baseId = normalizeScriptId(name);
90 if (!taken.has(baseId)) return baseId;
91
92 let suffix = 2;
93 while (suffix < 10_000) {
94 const candidate = `${baseId}-${suffix}`;
95 const safeCandidate =
96 candidate.length <= MAX_SCRIPT_ID_LENGTH
97 ? candidate
98 : `${baseId.slice(0, Math.max(1, MAX_SCRIPT_ID_LENGTH - String(suffix).length - 1))}-${suffix}`;
99 if (!taken.has(safeCandidate)) {
100 return safeCandidate;
101 }
102 suffix += 1;
103 }
104
105 // This last-resort fallback only triggers after exhausting thousands of suffixes.
106 return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH);
107}
108
109export function primaryProjectScript(scripts: ReadonlyArray<ProjectScript>): ProjectScript | null {
110 const regular = scripts.find((script) => !script.runOnWorktreeCreate && !script.runOnSettle);
111 return regular ?? scripts.find((script) => !script.runOnSettle) ?? null;
112}