@zq-silk/yui 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +141 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/agent/adapterCatalog.js +10 -0
- package/dist/agent/agent.js +89 -0
- package/dist/agent/agentRegistry.js +10 -0
- package/dist/agent/argumentPolicy.js +80 -0
- package/dist/brief/taskBrief.js +37 -0
- package/dist/cli/commandCatalog.js +647 -0
- package/dist/cli/completion.js +111 -0
- package/dist/cli/completionWizard.js +143 -0
- package/dist/cli/dynamicCompletion.js +48 -0
- package/dist/cli/helpRenderer.js +32 -0
- package/dist/cli/interactionCandidates.js +139 -0
- package/dist/cli/interactionPolicy.js +389 -0
- package/dist/cli/interactiveSelection.js +185 -0
- package/dist/cli/invocationRouter.js +51 -0
- package/dist/cli/roleOptionCatalog.js +67 -0
- package/dist/cli/roleWizard.js +546 -0
- package/dist/cli/selectionPorts.js +1 -0
- package/dist/cli/updateCommand.js +22 -0
- package/dist/cli.js +402 -0
- package/dist/commands/agentCommands.js +196 -0
- package/dist/commands/globalRoleCommands.js +367 -0
- package/dist/commands/jobCommands.js +100 -0
- package/dist/commands/operatorCommands.js +38 -0
- package/dist/commands/repositoryCommands.js +86 -0
- package/dist/commands/roleConfiguration.js +201 -0
- package/dist/commands/taskCommands.js +1344 -0
- package/dist/commands/taskContextCommand.js +215 -0
- package/dist/commands/taskInputCommands.js +423 -0
- package/dist/commands/taskRoleRuntimeStatus.js +152 -0
- package/dist/completion/completionInstaller.js +168 -0
- package/dist/completion/completionPort.js +1 -0
- package/dist/completion/completionState.js +137 -0
- package/dist/completion/completionWizard.js +125 -0
- package/dist/completion/fileCompletionManager.js +51 -0
- package/dist/config/yuiConfig.js +17 -0
- package/dist/context/dispatchContext.js +74 -0
- package/dist/controller/clientRuntime.js +215 -0
- package/dist/controller/controller.js +158 -0
- package/dist/controller/controllerMain.js +37 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +322 -0
- package/dist/controller/runtime.js +31 -0
- package/dist/controller/sessionNotify.js +136 -0
- package/dist/core/controllerClient.js +127 -0
- package/dist/core/controllerServer.js +269 -0
- package/dist/core/protocol.js +169 -0
- package/dist/decision/decision.js +42 -0
- package/dist/doctor/doctor.js +229 -0
- package/dist/errors/cliError.js +38 -0
- package/dist/event/taskEvent.js +44 -0
- package/dist/executor/agentAdapter.js +338 -0
- package/dist/executor/agentExecutor.js +144 -0
- package/dist/executor/executorRegistry.js +101 -0
- package/dist/executor/fileRoleLaunchPlanner.js +156 -0
- package/dist/executor/launchPlan.js +16 -0
- package/dist/input/inputRequest.js +326 -0
- package/dist/message/message.js +69 -0
- package/dist/milestone/milestone.js +27 -0
- package/dist/operator/operatorContext.js +66 -0
- package/dist/output/rolePresentation.js +82 -0
- package/dist/output/table.js +77 -0
- package/dist/output/terminal.js +198 -0
- package/dist/repository/gitWorkspace.js +210 -0
- package/dist/repository/repository.js +55 -0
- package/dist/repository/taskWorkspacePreparer.js +256 -0
- package/dist/role/role.js +246 -0
- package/dist/role/systemRoles.js +20 -0
- package/dist/run/agentRun.js +102 -0
- package/dist/scheduler/activeRoleRunDelivery.js +94 -0
- package/dist/scheduler/archivedTaskRuntime.js +12 -0
- package/dist/scheduler/leaderFailure.js +18 -0
- package/dist/scheduler/leaderWakeupProcessor.js +143 -0
- package/dist/scheduler/operatorInputNotificationProcessor.js +85 -0
- package/dist/scheduler/operatorNotification.js +17 -0
- package/dist/scheduler/pendingWakeup.js +33 -0
- package/dist/scheduler/ports.js +1 -0
- package/dist/scheduler/roleRunLiveness.js +41 -0
- package/dist/scheduler/wakeupQueue.js +13 -0
- package/dist/setup/setupCommand.js +317 -0
- package/dist/storage/durableFile.js +38 -0
- package/dist/storage/storageSchema.js +259 -0
- package/dist/storage/taskStore.js +1032 -0
- package/dist/task/task.js +216 -0
- package/dist/tmux/commandExecutor.js +69 -0
- package/dist/tmux/terminalHandoff.js +17 -0
- package/dist/tmux/tmuxManager.js +408 -0
- package/dist/workItem/workItem.js +45 -0
- package/dist/worktree/roleWorkspace.js +62 -0
- package/i18n/README.zh-CN.md +205 -0
- package/package.json +47 -0
- package/skills/yui-leader/SKILL.md +72 -0
- package/skills/yui-operator/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +31 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
2
|
+
const RESET = "\x1b[0m";
|
|
3
|
+
const BOLD = "\x1b[1m";
|
|
4
|
+
const DIM = "\x1b[2m";
|
|
5
|
+
const RED = "\x1b[31m";
|
|
6
|
+
const GREEN = "\x1b[32m";
|
|
7
|
+
const YELLOW = "\x1b[33m";
|
|
8
|
+
const CYAN = "\x1b[36m";
|
|
9
|
+
export function renderSection(title, body) {
|
|
10
|
+
const normalizedTitle = title.trim();
|
|
11
|
+
const normalizedBody = body?.trimEnd();
|
|
12
|
+
if (normalizedBody === undefined || normalizedBody.length === 0)
|
|
13
|
+
return normalizedTitle;
|
|
14
|
+
return `${normalizedTitle}\n${normalizedBody.split("\n").map((line) => ` ${line}`).join("\n")}`;
|
|
15
|
+
}
|
|
16
|
+
export function renderDetails(entries) {
|
|
17
|
+
if (entries.length === 0)
|
|
18
|
+
return "";
|
|
19
|
+
const labelWidth = Math.max(...entries.map(([label]) => visibleWidth(label)));
|
|
20
|
+
return entries.map(([label, value]) => ` ${padVisibleEnd(label, labelWidth)} ${value.length === 0 ? "—" : value}`).join("\n");
|
|
21
|
+
}
|
|
22
|
+
export function renderCodeBlock(contents) {
|
|
23
|
+
return contents.trimEnd().split("\n").map((line) => ` │ ${line}`).join("\n");
|
|
24
|
+
}
|
|
25
|
+
export function renderPrompt(question, hint) {
|
|
26
|
+
const suffix = hint === undefined || hint.length === 0 ? "" : ` [${hint}]`;
|
|
27
|
+
return `› ${question.trim()}${suffix}: `;
|
|
28
|
+
}
|
|
29
|
+
export function withPromptAnswerSpacing(question, write, inputIsEchoed) {
|
|
30
|
+
return async (prompt) => {
|
|
31
|
+
const answer = await question(prompt);
|
|
32
|
+
write(inputIsEchoed ? "\n" : "\n\n");
|
|
33
|
+
return answer;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function renderSuccess(message) {
|
|
37
|
+
return renderOutcome("✓", message);
|
|
38
|
+
}
|
|
39
|
+
export function renderInfo(message) {
|
|
40
|
+
return renderOutcome("›", message);
|
|
41
|
+
}
|
|
42
|
+
export function renderWarning(message) {
|
|
43
|
+
return renderOutcome("!", message);
|
|
44
|
+
}
|
|
45
|
+
export function renderError(message) {
|
|
46
|
+
return renderOutcome("✕", message);
|
|
47
|
+
}
|
|
48
|
+
export function renderEmpty(message) {
|
|
49
|
+
return renderOutcome("○", message);
|
|
50
|
+
}
|
|
51
|
+
function renderOutcome(symbol, message) {
|
|
52
|
+
const lines = message.trimEnd().split("\n");
|
|
53
|
+
return `${lines.map((line, index) => index === 0 ? `${symbol} ${line}` : ` ${line}`).join("\n")}\n`;
|
|
54
|
+
}
|
|
55
|
+
export function terminalSupportsColor(stream, env = process.env) {
|
|
56
|
+
if (Object.hasOwn(env, "NO_COLOR"))
|
|
57
|
+
return false;
|
|
58
|
+
if (env.FORCE_COLOR === "0")
|
|
59
|
+
return false;
|
|
60
|
+
if (env.FORCE_COLOR !== undefined)
|
|
61
|
+
return true;
|
|
62
|
+
return stream.isTTY === true && env.TERM !== "dumb";
|
|
63
|
+
}
|
|
64
|
+
export function defaultTerminalWidth(stream = process.stdout) {
|
|
65
|
+
const columns = stream.columns;
|
|
66
|
+
return columns === undefined || !Number.isFinite(columns) || columns <= 0
|
|
67
|
+
? 100
|
|
68
|
+
: Math.max(20, Math.min(Math.floor(columns), 140));
|
|
69
|
+
}
|
|
70
|
+
export function paintTerminalOutput(output, color) {
|
|
71
|
+
if (!color || output.length === 0 || ANSI_PATTERN.test(output)) {
|
|
72
|
+
ANSI_PATTERN.lastIndex = 0;
|
|
73
|
+
return output;
|
|
74
|
+
}
|
|
75
|
+
const lines = output.split("\n");
|
|
76
|
+
return lines.map((line, index) => paintLine(line, lines, index)).join("\n");
|
|
77
|
+
}
|
|
78
|
+
function paintLine(line, lines, index) {
|
|
79
|
+
if (line.startsWith("✓ "))
|
|
80
|
+
return `${GREEN}✓${RESET}${line.slice(1)}`;
|
|
81
|
+
if (line.startsWith("! "))
|
|
82
|
+
return `${YELLOW}!${RESET}${line.slice(1)}`;
|
|
83
|
+
if (line.startsWith("✕ "))
|
|
84
|
+
return `${RED}✕${RESET}${line.slice(1)}`;
|
|
85
|
+
if (line.startsWith("› "))
|
|
86
|
+
return `${CYAN}›${RESET}${line.slice(1)}`;
|
|
87
|
+
if (line.startsWith("○ "))
|
|
88
|
+
return `${DIM}${line}${RESET}`;
|
|
89
|
+
if (/^\s*─+(?:\s+─+)*\s*$/.test(line))
|
|
90
|
+
return `${DIM}${line}${RESET}`;
|
|
91
|
+
if (line.startsWith(" │ "))
|
|
92
|
+
return ` ${DIM}│${RESET}${line.slice(3)}`;
|
|
93
|
+
if (isHeadingLine(line, lines, index))
|
|
94
|
+
return `${BOLD}${line}${RESET}`;
|
|
95
|
+
return line;
|
|
96
|
+
}
|
|
97
|
+
function isHeadingLine(line, lines, index) {
|
|
98
|
+
if (line.length === 0 || /^\s/.test(line) || /^(?:#|\{|\[)/.test(line))
|
|
99
|
+
return false;
|
|
100
|
+
if (/^[A-Z][A-Z0-9_]+: /.test(line) || /^[^:]{1,24}:\s+\S/.test(line))
|
|
101
|
+
return false;
|
|
102
|
+
const previousIsBlank = index === 0 || lines[index - 1]?.length === 0;
|
|
103
|
+
const nextIsBlank = index + 1 < lines.length && lines[index + 1]?.length === 0;
|
|
104
|
+
return nextIsBlank || previousIsBlank && index === 0;
|
|
105
|
+
}
|
|
106
|
+
export function visibleWidth(value) {
|
|
107
|
+
const plain = value.replace(ANSI_PATTERN, "");
|
|
108
|
+
let width = 0;
|
|
109
|
+
for (const segment of graphemes(plain))
|
|
110
|
+
width += graphemeWidth(segment);
|
|
111
|
+
return width;
|
|
112
|
+
}
|
|
113
|
+
export function padVisibleEnd(value, width) {
|
|
114
|
+
return `${value}${" ".repeat(Math.max(0, width - visibleWidth(value)))}`;
|
|
115
|
+
}
|
|
116
|
+
export function wrapVisibleText(value, width) {
|
|
117
|
+
const safeWidth = Math.max(1, Math.floor(width));
|
|
118
|
+
return value.replaceAll("\t", " ").split("\n")
|
|
119
|
+
.flatMap((paragraph) => wrapParagraph(paragraph, safeWidth));
|
|
120
|
+
}
|
|
121
|
+
function wrapParagraph(value, width) {
|
|
122
|
+
if (value.length === 0)
|
|
123
|
+
return [""];
|
|
124
|
+
const words = value.trim().split(/\s+/);
|
|
125
|
+
const lines = [];
|
|
126
|
+
let current = "";
|
|
127
|
+
for (const word of words) {
|
|
128
|
+
for (const chunk of splitVisible(word, width)) {
|
|
129
|
+
if (current.length === 0) {
|
|
130
|
+
current = chunk;
|
|
131
|
+
}
|
|
132
|
+
else if (visibleWidth(current) + 1 + visibleWidth(chunk) <= width) {
|
|
133
|
+
current = `${current} ${chunk}`;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
lines.push(current);
|
|
137
|
+
current = chunk;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (current.length > 0 || lines.length === 0)
|
|
142
|
+
lines.push(current);
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
|
145
|
+
function splitVisible(value, width) {
|
|
146
|
+
const chunks = [];
|
|
147
|
+
let chunk = "";
|
|
148
|
+
let chunkWidth = 0;
|
|
149
|
+
for (const segment of graphemes(value)) {
|
|
150
|
+
const segmentWidth = graphemeWidth(segment);
|
|
151
|
+
if (chunk.length > 0 && chunkWidth + segmentWidth > width) {
|
|
152
|
+
chunks.push(chunk);
|
|
153
|
+
chunk = "";
|
|
154
|
+
chunkWidth = 0;
|
|
155
|
+
}
|
|
156
|
+
chunk += segment;
|
|
157
|
+
chunkWidth += segmentWidth;
|
|
158
|
+
}
|
|
159
|
+
if (chunk.length > 0 || chunks.length === 0)
|
|
160
|
+
chunks.push(chunk);
|
|
161
|
+
return chunks;
|
|
162
|
+
}
|
|
163
|
+
function graphemes(value) {
|
|
164
|
+
if (typeof Intl.Segmenter === "function") {
|
|
165
|
+
return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value)]
|
|
166
|
+
.map(({ segment }) => segment);
|
|
167
|
+
}
|
|
168
|
+
return [...value];
|
|
169
|
+
}
|
|
170
|
+
function graphemeWidth(value) {
|
|
171
|
+
if (value.length === 0)
|
|
172
|
+
return 0;
|
|
173
|
+
if (/\p{Extended_Pictographic}/u.test(value))
|
|
174
|
+
return 2;
|
|
175
|
+
let width = 0;
|
|
176
|
+
for (const character of value) {
|
|
177
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
178
|
+
if (codePoint === 0 || codePoint < 32 || codePoint >= 0x7f && codePoint < 0xa0)
|
|
179
|
+
continue;
|
|
180
|
+
if (/\p{Mark}/u.test(character) || codePoint === 0x200d || codePoint >= 0xfe00 && codePoint <= 0xfe0f)
|
|
181
|
+
continue;
|
|
182
|
+
width = Math.max(width, isWideCodePoint(codePoint) ? 2 : 1);
|
|
183
|
+
}
|
|
184
|
+
return width;
|
|
185
|
+
}
|
|
186
|
+
function isWideCodePoint(codePoint) {
|
|
187
|
+
return codePoint >= 0x1100 && (codePoint <= 0x115f
|
|
188
|
+
|| codePoint === 0x2329 || codePoint === 0x232a
|
|
189
|
+
|| codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f
|
|
190
|
+
|| codePoint >= 0xac00 && codePoint <= 0xd7a3
|
|
191
|
+
|| codePoint >= 0xf900 && codePoint <= 0xfaff
|
|
192
|
+
|| codePoint >= 0xfe10 && codePoint <= 0xfe19
|
|
193
|
+
|| codePoint >= 0xfe30 && codePoint <= 0xfe6f
|
|
194
|
+
|| codePoint >= 0xff00 && codePoint <= 0xff60
|
|
195
|
+
|| codePoint >= 0xffe0 && codePoint <= 0xffe6
|
|
196
|
+
|| codePoint >= 0x1b000 && codePoint <= 0x1b2ff
|
|
197
|
+
|| codePoint >= 0x20000 && codePoint <= 0x3fffd);
|
|
198
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const executeFile = promisify(execFile);
|
|
7
|
+
/** The small Git boundary used by repository registration and Task workspaces. */
|
|
8
|
+
export class NodeGitWorkspace {
|
|
9
|
+
async inspect(repositoryPath, baseRef = "HEAD") {
|
|
10
|
+
const requested = await canonicalDirectory(repositoryPath, "Repository");
|
|
11
|
+
const ref = safeRef(baseRef);
|
|
12
|
+
const root = await canonicalDirectory(await gitLine(["-C", requested, "rev-parse", "--show-toplevel"]), "Repository root");
|
|
13
|
+
const gitDirectory = await canonicalDirectory(await gitLine([
|
|
14
|
+
"-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir"
|
|
15
|
+
]), "Git common directory");
|
|
16
|
+
const baseCommit = await gitLine([
|
|
17
|
+
"-C", root, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`
|
|
18
|
+
]);
|
|
19
|
+
if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(baseCommit)) {
|
|
20
|
+
throw new Error("Git returned an invalid base commit.");
|
|
21
|
+
}
|
|
22
|
+
return { root, gitDirectory, baseRef: ref, baseCommit: baseCommit.toLowerCase() };
|
|
23
|
+
}
|
|
24
|
+
async ensureWorktree(input) {
|
|
25
|
+
const container = await canonicalContainer(input.container, true);
|
|
26
|
+
const identity = worktreeIdentity(input.taskId, input.roleName);
|
|
27
|
+
const path = managedPath(container, identity.directory);
|
|
28
|
+
const kind = await pathKind(path);
|
|
29
|
+
if (kind === "symlink")
|
|
30
|
+
throw new Error("Managed worktree path must not be a symbolic link.");
|
|
31
|
+
if (kind === "directory") {
|
|
32
|
+
// A worktree already created before a crash remains usable even if the
|
|
33
|
+
// original base branch/tag is later deleted.
|
|
34
|
+
const repository = await this.inspect(input.repositoryPath);
|
|
35
|
+
await assertOwnedWorktree(repository, container, path);
|
|
36
|
+
await assertExpectedBranch(path, identity.branch);
|
|
37
|
+
const head = await gitLine([
|
|
38
|
+
"-C", path, "rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"
|
|
39
|
+
]);
|
|
40
|
+
return { path, branch: identity.branch, baseCommit: head.toLowerCase() };
|
|
41
|
+
}
|
|
42
|
+
const repository = await this.inspect(input.repositoryPath, input.baseRef);
|
|
43
|
+
await canonicalContainer(dirname(path), true);
|
|
44
|
+
const branchRef = `refs/heads/${identity.branch}`;
|
|
45
|
+
const branchExists = await gitSucceeds([
|
|
46
|
+
"-C", repository.root, "show-ref", "--verify", "--quiet", branchRef
|
|
47
|
+
]);
|
|
48
|
+
await git(branchExists
|
|
49
|
+
? ["-C", repository.root, "worktree", "add", "--", path, identity.branch]
|
|
50
|
+
: [
|
|
51
|
+
"-C", repository.root, "worktree", "add", "-b", identity.branch,
|
|
52
|
+
"--", path, repository.baseCommit
|
|
53
|
+
]);
|
|
54
|
+
await assertOwnedWorktree(repository, container, path);
|
|
55
|
+
await assertExpectedBranch(path, identity.branch);
|
|
56
|
+
return { path, branch: identity.branch, baseCommit: repository.baseCommit };
|
|
57
|
+
}
|
|
58
|
+
async removeWorktree(input) {
|
|
59
|
+
const container = resolve(input.container);
|
|
60
|
+
const path = managedPath(container, worktreeIdentity(input.taskId, input.roleName).directory);
|
|
61
|
+
const kind = await pathKind(path);
|
|
62
|
+
if (kind === undefined)
|
|
63
|
+
return "missing";
|
|
64
|
+
if (kind === "symlink")
|
|
65
|
+
throw new Error("Managed worktree path must not be a symbolic link.");
|
|
66
|
+
const canonicalContainerPath = await canonicalContainer(container, false);
|
|
67
|
+
const repository = await this.inspect(input.repositoryPath);
|
|
68
|
+
await assertOwnedWorktree(repository, canonicalContainerPath, path);
|
|
69
|
+
const porcelain = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
|
|
70
|
+
if (porcelain.length > 0)
|
|
71
|
+
return "dirty";
|
|
72
|
+
await git(["-C", repository.root, "worktree", "remove", "--", path]);
|
|
73
|
+
return "removed";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function worktreeIdentity(taskId, roleName) {
|
|
77
|
+
const taskKey = safeIdentity(taskId, "Task id");
|
|
78
|
+
const roleKey = safeIdentity(roleName, "Role name");
|
|
79
|
+
return {
|
|
80
|
+
directory: join(taskKey, roleKey),
|
|
81
|
+
branch: `yui/${gitRefSegment(taskKey)}/${gitRefSegment(roleKey)}`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function gitRefSegment(identity) {
|
|
85
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(identity)
|
|
86
|
+
&& !identity.includes("..")
|
|
87
|
+
&& !identity.endsWith(".")
|
|
88
|
+
&& !identity.endsWith(".lock")) {
|
|
89
|
+
return identity;
|
|
90
|
+
}
|
|
91
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
92
|
+
return `encoded-${digest}`;
|
|
93
|
+
}
|
|
94
|
+
async function assertExpectedBranch(path, expected) {
|
|
95
|
+
const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
|
|
96
|
+
if (branch !== expected) {
|
|
97
|
+
throw new Error(`Managed worktree is on an unexpected branch: ${branch}.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function assertOwnedWorktree(repository, container, path) {
|
|
101
|
+
const canonicalPath = await canonicalDirectory(path, "Managed worktree");
|
|
102
|
+
assertContained(container, canonicalPath);
|
|
103
|
+
if (canonicalPath !== path)
|
|
104
|
+
throw new Error("Managed worktree resolves through a symbolic link.");
|
|
105
|
+
const root = await canonicalDirectory(await gitLine(["-C", path, "rev-parse", "--show-toplevel"]), "Managed worktree root");
|
|
106
|
+
if (root !== path)
|
|
107
|
+
throw new Error("Managed worktree root does not match its deterministic path.");
|
|
108
|
+
const common = await canonicalDirectory(await gitLine(["-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"]), "Managed Git common directory");
|
|
109
|
+
if (common !== repository.gitDirectory) {
|
|
110
|
+
throw new Error("Managed worktree belongs to another repository.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function canonicalContainer(path, create) {
|
|
114
|
+
const lexical = resolve(path);
|
|
115
|
+
if (create)
|
|
116
|
+
await mkdir(lexical, { recursive: true, mode: 0o700 });
|
|
117
|
+
const canonical = await canonicalDirectory(lexical, "Worktree container");
|
|
118
|
+
if (canonical !== lexical)
|
|
119
|
+
throw new Error("Worktree container resolves through a symbolic link.");
|
|
120
|
+
return canonical;
|
|
121
|
+
}
|
|
122
|
+
async function canonicalDirectory(path, label) {
|
|
123
|
+
const value = requireText(path, label);
|
|
124
|
+
const canonical = await realpath(isAbsolute(value) ? value : resolve(value));
|
|
125
|
+
return canonical;
|
|
126
|
+
}
|
|
127
|
+
function managedPath(container, directory) {
|
|
128
|
+
const path = join(container, directory);
|
|
129
|
+
assertContained(container, path);
|
|
130
|
+
return path;
|
|
131
|
+
}
|
|
132
|
+
function assertContained(container, path) {
|
|
133
|
+
const child = relative(container, path);
|
|
134
|
+
if (child.length === 0 || child === ".." || child.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(child)) {
|
|
135
|
+
throw new Error("Managed worktree path escapes its container.");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function pathKind(path) {
|
|
139
|
+
try {
|
|
140
|
+
const entry = await lstat(path);
|
|
141
|
+
if (entry.isSymbolicLink())
|
|
142
|
+
return "symlink";
|
|
143
|
+
if (!entry.isDirectory())
|
|
144
|
+
throw new Error("Managed worktree path is not a directory.");
|
|
145
|
+
return "directory";
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (isErrno(error, "ENOENT"))
|
|
149
|
+
return undefined;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function git(args) {
|
|
154
|
+
try {
|
|
155
|
+
const result = await executeFile("git", [...args], {
|
|
156
|
+
encoding: "utf8",
|
|
157
|
+
maxBuffer: 1024 * 1024,
|
|
158
|
+
timeout: 30_000
|
|
159
|
+
});
|
|
160
|
+
return result.stdout;
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
const stderr = typeof error === "object" && error !== null && "stderr" in error
|
|
164
|
+
? String(error.stderr).trim()
|
|
165
|
+
: "";
|
|
166
|
+
throw new Error(stderr.length === 0 ? "Git command failed." : `Git command failed: ${stderr}`, {
|
|
167
|
+
cause: error
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function gitSucceeds(args) {
|
|
172
|
+
try {
|
|
173
|
+
await git(args);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function gitLine(args) {
|
|
181
|
+
const lines = (await git(args)).trimEnd().split("\n");
|
|
182
|
+
if (lines.length !== 1 || lines[0]?.length === 0 || lines[0]?.includes("\0")) {
|
|
183
|
+
throw new Error("Git returned invalid output.");
|
|
184
|
+
}
|
|
185
|
+
return lines[0];
|
|
186
|
+
}
|
|
187
|
+
function safeIdentity(value, label) {
|
|
188
|
+
const identity = requireText(value, label);
|
|
189
|
+
if ([".", "..", "__proto__", "prototype", "constructor"].includes(identity)
|
|
190
|
+
|| /[\/\\\0]/.test(identity)) {
|
|
191
|
+
throw new Error(`${label} is invalid.`);
|
|
192
|
+
}
|
|
193
|
+
return identity;
|
|
194
|
+
}
|
|
195
|
+
function safeRef(value) {
|
|
196
|
+
const ref = requireText(value, "Git base ref");
|
|
197
|
+
if (ref.startsWith("-") || /[\r\n]/.test(ref))
|
|
198
|
+
throw new Error("Git base ref is invalid.");
|
|
199
|
+
return ref;
|
|
200
|
+
}
|
|
201
|
+
function requireText(value, label) {
|
|
202
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {
|
|
203
|
+
throw new Error(`${label} is required.`);
|
|
204
|
+
}
|
|
205
|
+
return value.trim();
|
|
206
|
+
}
|
|
207
|
+
function isErrno(value, code) {
|
|
208
|
+
return typeof value === "object" && value !== null && "code" in value
|
|
209
|
+
&& value.code === code;
|
|
210
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
export function createRepository(id, name, path, defaultBranch, now) {
|
|
3
|
+
const timestamp = now.toISOString();
|
|
4
|
+
return validateRepository({
|
|
5
|
+
schemaVersion: 1,
|
|
6
|
+
id: requireIdentity(id, "Repository id"),
|
|
7
|
+
name: requireText(name, "Repository name"),
|
|
8
|
+
path: resolve(requireText(path, "Repository path")),
|
|
9
|
+
defaultBranch: requireGitRef(defaultBranch),
|
|
10
|
+
createdAt: timestamp,
|
|
11
|
+
updatedAt: timestamp
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function validateRepository(repository) {
|
|
15
|
+
if (repository.schemaVersion !== 1) {
|
|
16
|
+
throw new Error("Repository must use schemaVersion 1.");
|
|
17
|
+
}
|
|
18
|
+
requireIdentity(repository.id, "Repository id");
|
|
19
|
+
requireText(repository.name, "Repository name");
|
|
20
|
+
if (resolve(requireText(repository.path, "Repository path")) !== repository.path) {
|
|
21
|
+
throw new Error("Repository path must be absolute and normalized.");
|
|
22
|
+
}
|
|
23
|
+
requireGitRef(repository.defaultBranch);
|
|
24
|
+
requireTimestamp(repository.createdAt, "Repository createdAt");
|
|
25
|
+
requireTimestamp(repository.updatedAt, "Repository updatedAt");
|
|
26
|
+
return repository;
|
|
27
|
+
}
|
|
28
|
+
function requireIdentity(value, label) {
|
|
29
|
+
const normalized = requireText(value, label);
|
|
30
|
+
if (["__proto__", "prototype", "constructor", ".", ".."].includes(normalized)
|
|
31
|
+
|| /[\/\\\0]/.test(normalized)) {
|
|
32
|
+
throw new Error(`${label} is invalid.`);
|
|
33
|
+
}
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
function requireGitRef(value) {
|
|
37
|
+
const ref = requireText(value, "Repository base ref");
|
|
38
|
+
if (ref.startsWith("-") || /[\r\n]/.test(ref)) {
|
|
39
|
+
throw new Error("Repository base ref is invalid.");
|
|
40
|
+
}
|
|
41
|
+
return ref;
|
|
42
|
+
}
|
|
43
|
+
function requireText(value, label) {
|
|
44
|
+
if (typeof value !== "string" || value.includes("\0"))
|
|
45
|
+
throw new Error(`${label} is invalid.`);
|
|
46
|
+
const normalized = value.trim();
|
|
47
|
+
if (normalized.length === 0)
|
|
48
|
+
throw new Error(`${label} is required.`);
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
function requireTimestamp(value, label) {
|
|
52
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
|
|
53
|
+
throw new Error(`${label} is invalid.`);
|
|
54
|
+
}
|
|
55
|
+
}
|