@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,317 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { configuredAgentToDefinition, createConfiguredAgent } from "../agent/agent.js";
|
|
5
|
+
import { runCompletionWizard } from "../completion/completionWizard.js";
|
|
6
|
+
import { usageError } from "../errors/cliError.js";
|
|
7
|
+
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
8
|
+
import { createGlobalRole, createRoleAgentBinding } from "../role/role.js";
|
|
9
|
+
import { SYSTEM_LEADER_ROLE, SYSTEM_OPERATOR_ROLE } from "../role/systemRoles.js";
|
|
10
|
+
import { ensureYuiHome, FileTaskStore, resolveYuiHome } from "../storage/taskStore.js";
|
|
11
|
+
import { ensureStorageSchema } from "../storage/storageSchema.js";
|
|
12
|
+
const BUILTIN_AGENTS = Object.freeze([
|
|
13
|
+
Object.freeze({
|
|
14
|
+
id: "codex",
|
|
15
|
+
adapterId: "codex",
|
|
16
|
+
command: "codex",
|
|
17
|
+
description: "OpenAI Codex CLI"
|
|
18
|
+
}),
|
|
19
|
+
Object.freeze({
|
|
20
|
+
id: "claude",
|
|
21
|
+
adapterId: "claude",
|
|
22
|
+
command: "claude",
|
|
23
|
+
description: "Anthropic Claude Code"
|
|
24
|
+
})
|
|
25
|
+
]);
|
|
26
|
+
export async function runSetupCommand(args, env, executor, io = {}) {
|
|
27
|
+
const dependency = parseSetupOptions(args);
|
|
28
|
+
if (!shouldPrompt(io))
|
|
29
|
+
throw setupRequiresInteractiveError();
|
|
30
|
+
const readline = createInterface({
|
|
31
|
+
input: io.input,
|
|
32
|
+
output: io.output,
|
|
33
|
+
terminal: io.input.isTTY === true
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
const question = createSetupQuestion(readline, io);
|
|
37
|
+
const home = resolveYuiHome(env);
|
|
38
|
+
ensureYuiHome(home);
|
|
39
|
+
ensureStorageSchema(home);
|
|
40
|
+
const store = new FileTaskStore(home);
|
|
41
|
+
const result = await configureYui(store, home, env, question, io);
|
|
42
|
+
const lines = [
|
|
43
|
+
"Yui home initialized.",
|
|
44
|
+
`Agents configured: ${result.agentIds.join(", ")}.`,
|
|
45
|
+
`Default Agent: ${result.defaultAgentId}.`,
|
|
46
|
+
`Operator Agent: ${result.operatorAgentId}.`,
|
|
47
|
+
`Operator workspace: ${result.workspace}.`
|
|
48
|
+
];
|
|
49
|
+
if (dependency === undefined || dependency === "tmux") {
|
|
50
|
+
lines.push(...await setupTmux(env, executor, question));
|
|
51
|
+
}
|
|
52
|
+
const completion = await runCompletionWizard("install", store, env, cliIdentity(env), question, { width: tableWidth(io), defaultSelection: "skip" });
|
|
53
|
+
lines.push(completion.trimEnd());
|
|
54
|
+
lines.push("Yui setup complete.");
|
|
55
|
+
return `${lines.join("\n")}\n`;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
readline.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function validateSetupInvocation(args, io = {}) {
|
|
62
|
+
parseSetupOptions(args);
|
|
63
|
+
if (!shouldPrompt(io))
|
|
64
|
+
throw setupRequiresInteractiveError();
|
|
65
|
+
}
|
|
66
|
+
async function configureYui(store, home, env, question, io) {
|
|
67
|
+
const candidates = availableAgentChoices(store, env);
|
|
68
|
+
if (candidates.length === 0) {
|
|
69
|
+
throw usageError("No supported Agent CLI was found. Install Codex or Claude, then run setup again.");
|
|
70
|
+
}
|
|
71
|
+
io.output?.write(`${renderTable("Available Agents", [
|
|
72
|
+
{ header: "#", minWidth: 1, maxWidth: 3 },
|
|
73
|
+
{ header: "Agent", minWidth: 6, maxWidth: 16 },
|
|
74
|
+
{ header: "Adapter", minWidth: 7, maxWidth: 10 },
|
|
75
|
+
{ header: "Command", minWidth: 7, maxWidth: 24 },
|
|
76
|
+
{ header: "Description", minWidth: 12, maxWidth: 42 }
|
|
77
|
+
], candidates.map((candidate, index) => [
|
|
78
|
+
String(index + 1),
|
|
79
|
+
candidate.id,
|
|
80
|
+
candidate.adapterId,
|
|
81
|
+
candidate.command,
|
|
82
|
+
candidate.description
|
|
83
|
+
]), tableWidth(io))}\n`);
|
|
84
|
+
const selected = parseAgentSetSelection(await question(`Choose Agents by number or name, comma-separated [all: ${candidates.map(({ id }) => id).join(", ")}]: `), candidates);
|
|
85
|
+
const now = new Date();
|
|
86
|
+
const persisted = selected.map((choice) => persistAgent(store, choice, now));
|
|
87
|
+
const configuredIds = new Set(persisted.map(({ id }) => id));
|
|
88
|
+
const config = store.getConfig();
|
|
89
|
+
const defaultFallback = configuredIds.has(config.defaultAgent ?? "")
|
|
90
|
+
? config.defaultAgent
|
|
91
|
+
: persisted[0]?.id;
|
|
92
|
+
if (defaultFallback === undefined)
|
|
93
|
+
throw usageError("At least one Agent must be selected.");
|
|
94
|
+
const defaultAgentId = parseSingleAgentSelection(await question(`Choose default Agent [${defaultFallback}]: `), persisted, defaultFallback);
|
|
95
|
+
const currentOperatorAgent = store.getGlobalRole(SYSTEM_OPERATOR_ROLE)?.activeAgentId;
|
|
96
|
+
const operatorFallback = configuredIds.has(currentOperatorAgent ?? "")
|
|
97
|
+
? currentOperatorAgent
|
|
98
|
+
: defaultAgentId;
|
|
99
|
+
const operatorAgentId = parseSingleAgentSelection(await question(`Choose Operator Agent [${operatorFallback}]: `), persisted, operatorFallback);
|
|
100
|
+
const suggestedWorkspace = config.defaultWorkspace?.trim() || join(home, "workspace");
|
|
101
|
+
const workspaceAnswer = (await question(`Operator workspace [${suggestedWorkspace}]: `)).trim();
|
|
102
|
+
const workspace = resolveWorkspace(workspaceAnswer || suggestedWorkspace);
|
|
103
|
+
const defaultAgent = persisted.find(({ id }) => id === defaultAgentId);
|
|
104
|
+
const operatorAgent = persisted.find(({ id }) => id === operatorAgentId);
|
|
105
|
+
if (defaultAgent === undefined || operatorAgent === undefined) {
|
|
106
|
+
throw usageError("Selected setup Agent is no longer available.");
|
|
107
|
+
}
|
|
108
|
+
assertSystemRoleCompatible(store, SYSTEM_OPERATOR_ROLE, operatorAgent, workspace);
|
|
109
|
+
assertSystemRoleCompatible(store, SYSTEM_LEADER_ROLE, defaultAgent, workspace);
|
|
110
|
+
mkdirSync(workspace, { recursive: true, mode: 0o700 });
|
|
111
|
+
store.saveConfig({
|
|
112
|
+
...store.getConfig(),
|
|
113
|
+
defaultAgent: defaultAgentId,
|
|
114
|
+
defaultWorkspace: workspace
|
|
115
|
+
});
|
|
116
|
+
ensureSystemRole(store, SYSTEM_OPERATOR_ROLE, operatorAgent, workspace, now);
|
|
117
|
+
ensureSystemRole(store, SYSTEM_LEADER_ROLE, defaultAgent, workspace, now);
|
|
118
|
+
return {
|
|
119
|
+
agentIds: persisted.map(({ id }) => id),
|
|
120
|
+
defaultAgentId,
|
|
121
|
+
operatorAgentId,
|
|
122
|
+
workspace
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function availableAgentChoices(store, env) {
|
|
126
|
+
const existing = new Map(store.listConfiguredAgents().map((agent) => [agent.id, agent]));
|
|
127
|
+
return BUILTIN_AGENTS.flatMap((builtin) => {
|
|
128
|
+
const configured = existing.get(builtin.id);
|
|
129
|
+
const command = configured?.command ?? builtin.command;
|
|
130
|
+
if (!commandOnPath(command, env))
|
|
131
|
+
return [];
|
|
132
|
+
return [{
|
|
133
|
+
...builtin,
|
|
134
|
+
adapterId: configured?.adapterId ?? builtin.adapterId,
|
|
135
|
+
command
|
|
136
|
+
}];
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function persistAgent(store, choice, now) {
|
|
140
|
+
const existing = store.getConfiguredAgent(choice.id);
|
|
141
|
+
if (existing !== null
|
|
142
|
+
&& existing.adapterId === choice.adapterId
|
|
143
|
+
&& existing.command === choice.command) {
|
|
144
|
+
return existing;
|
|
145
|
+
}
|
|
146
|
+
const agent = createConfiguredAgent(choice.id, choice.adapterId, choice.command, existing?.baseArgs ?? [], existing?.environment ?? [], now);
|
|
147
|
+
store.saveConfiguredAgent(agent);
|
|
148
|
+
return agent;
|
|
149
|
+
}
|
|
150
|
+
function ensureSystemRole(store, name, agent, workspace, now) {
|
|
151
|
+
const definition = configuredAgentToDefinition(agent);
|
|
152
|
+
const existing = store.getGlobalRole(name);
|
|
153
|
+
if (existing !== null)
|
|
154
|
+
return;
|
|
155
|
+
store.saveGlobalRole(createGlobalRole(name, [createRoleAgentBinding(definition)], definition.id, workspace, now));
|
|
156
|
+
}
|
|
157
|
+
function assertSystemRoleCompatible(store, name, agent, workspace) {
|
|
158
|
+
const existing = store.getGlobalRole(name);
|
|
159
|
+
if (existing === null)
|
|
160
|
+
return;
|
|
161
|
+
if (existing.activeAgentId === agent.id && existing.workspace === workspace)
|
|
162
|
+
return;
|
|
163
|
+
throw usageError(`Global Role ${name} is already configured with Agent ${existing.activeAgentId} `
|
|
164
|
+
+ `and workspace ${existing.workspace}. Stop its Session and use role update before changing it.`);
|
|
165
|
+
}
|
|
166
|
+
function parseAgentSetSelection(answer, candidates) {
|
|
167
|
+
const value = answer.trim().toLowerCase();
|
|
168
|
+
if (value.length === 0 || value === "all")
|
|
169
|
+
return [...candidates];
|
|
170
|
+
const tokens = value.split(",").map((token) => token.trim()).filter(Boolean);
|
|
171
|
+
if (tokens.length === 0)
|
|
172
|
+
throw usageError("Select at least one Agent.");
|
|
173
|
+
const selected = [];
|
|
174
|
+
for (const token of tokens) {
|
|
175
|
+
const numeric = Number(token);
|
|
176
|
+
const choice = Number.isSafeInteger(numeric) && numeric >= 1 && numeric <= candidates.length
|
|
177
|
+
? candidates[numeric - 1]
|
|
178
|
+
: candidates.find(({ id }) => id.toLowerCase() === token);
|
|
179
|
+
if (choice === undefined) {
|
|
180
|
+
throw usageError("Choose available Agents by number or name, separated by commas.");
|
|
181
|
+
}
|
|
182
|
+
if (!selected.some(({ id }) => id === choice.id))
|
|
183
|
+
selected.push(choice);
|
|
184
|
+
}
|
|
185
|
+
return selected;
|
|
186
|
+
}
|
|
187
|
+
function parseSingleAgentSelection(answer, agents, fallback) {
|
|
188
|
+
const value = answer.trim().toLowerCase();
|
|
189
|
+
if (value.length === 0)
|
|
190
|
+
return fallback;
|
|
191
|
+
const numeric = Number(value);
|
|
192
|
+
const selected = Number.isSafeInteger(numeric) && numeric >= 1 && numeric <= agents.length
|
|
193
|
+
? agents[numeric - 1]
|
|
194
|
+
: agents.find(({ id }) => id.toLowerCase() === value);
|
|
195
|
+
if (selected === undefined) {
|
|
196
|
+
throw usageError("Choose one of the configured Agents by number or name.");
|
|
197
|
+
}
|
|
198
|
+
return selected.id;
|
|
199
|
+
}
|
|
200
|
+
function resolveWorkspace(value) {
|
|
201
|
+
if (!isAbsolute(value))
|
|
202
|
+
throw usageError("Operator workspace must be an absolute path.");
|
|
203
|
+
return resolve(value);
|
|
204
|
+
}
|
|
205
|
+
function commandOnPath(command, env) {
|
|
206
|
+
if (command.includes("/") || command.includes("\\"))
|
|
207
|
+
return existsSync(command);
|
|
208
|
+
const entries = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
209
|
+
const extensions = process.platform === "win32"
|
|
210
|
+
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
|
|
211
|
+
: [""];
|
|
212
|
+
return entries.some((entry) => extensions.some((extension) => existsSync(join(entry, `${command}${extension}`))));
|
|
213
|
+
}
|
|
214
|
+
function createSetupQuestion(readline, io) {
|
|
215
|
+
if (io.input.isTTY === true)
|
|
216
|
+
return (prompt) => readline.question(prompt);
|
|
217
|
+
const lines = readline[Symbol.asyncIterator]();
|
|
218
|
+
return async (prompt) => {
|
|
219
|
+
io.output.write(prompt);
|
|
220
|
+
const line = await lines.next();
|
|
221
|
+
return line.done ? "" : line.value;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function shouldPrompt(io) {
|
|
225
|
+
return io.input !== undefined
|
|
226
|
+
&& io.output !== undefined
|
|
227
|
+
&& (io.forceInteractive === true || io.input.isTTY === true);
|
|
228
|
+
}
|
|
229
|
+
function setupRequiresInteractiveError() {
|
|
230
|
+
return usageError("Setup requires an interactive terminal.");
|
|
231
|
+
}
|
|
232
|
+
function tableWidth(io) {
|
|
233
|
+
return io.output?.columns === undefined
|
|
234
|
+
? defaultTableWidth()
|
|
235
|
+
: Math.max(46, Math.min(io.output.columns, 140));
|
|
236
|
+
}
|
|
237
|
+
function cliIdentity(env) {
|
|
238
|
+
return env.YUI_CLI_NAME === "yui-dev" ? "yui-dev" : "yui";
|
|
239
|
+
}
|
|
240
|
+
function parseSetupOptions(args) {
|
|
241
|
+
if (args.length === 0)
|
|
242
|
+
return undefined;
|
|
243
|
+
if (args.length === 1 && args[0] === "tmux")
|
|
244
|
+
return "tmux";
|
|
245
|
+
throw usageError("Setup usage: yui setup [tmux]");
|
|
246
|
+
}
|
|
247
|
+
async function setupTmux(env, executor, question) {
|
|
248
|
+
const command = env.YUI_TMUX_BIN ?? "tmux";
|
|
249
|
+
if (hasExecutable(command, ["-V"], executor))
|
|
250
|
+
return ["Tmux already installed."];
|
|
251
|
+
const plan = detectTmuxInstallPlan(env, executor);
|
|
252
|
+
if (plan === null) {
|
|
253
|
+
return ["Tmux is not installed.", "Install tmux manually, then run yui doctor."];
|
|
254
|
+
}
|
|
255
|
+
const lines = [
|
|
256
|
+
"Tmux is not installed.",
|
|
257
|
+
`Install with ${plan.manager}:`,
|
|
258
|
+
...plan.steps.map((step) => ` ${step.command} ${step.args.join(" ")}`)
|
|
259
|
+
];
|
|
260
|
+
const answer = (await question("Install tmux now? [y/N]: ")).trim().toLowerCase();
|
|
261
|
+
if (answer !== "y" && answer !== "yes") {
|
|
262
|
+
return [...lines, "Skipped tmux installation.", "After installing tmux, run yui doctor."];
|
|
263
|
+
}
|
|
264
|
+
for (const step of plan.steps)
|
|
265
|
+
executor.run(step.command, [...step.args], { inheritStdio: true });
|
|
266
|
+
return hasExecutable(command, ["-V"], executor)
|
|
267
|
+
? [...lines, "Tmux installed."]
|
|
268
|
+
: [...lines, `Tmux install command completed, but ${command} is still unavailable.`, plan.manualHint];
|
|
269
|
+
}
|
|
270
|
+
function detectTmuxInstallPlan(env, executor) {
|
|
271
|
+
if (process.platform === "darwin" && commandExists("brew", env, executor)) {
|
|
272
|
+
return {
|
|
273
|
+
manager: "Homebrew",
|
|
274
|
+
steps: [{ command: "brew", args: ["install", "tmux"] }],
|
|
275
|
+
manualHint: "brew install tmux"
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (process.platform !== "linux")
|
|
279
|
+
return null;
|
|
280
|
+
for (const candidate of [
|
|
281
|
+
{ command: "apt-get", args: ["install", "-y", "tmux"], hint: "sudo apt-get install -y tmux" },
|
|
282
|
+
{ command: "dnf", args: ["install", "-y", "tmux"], hint: "sudo dnf install -y tmux" },
|
|
283
|
+
{ command: "pacman", args: ["-S", "--noconfirm", "tmux"], hint: "sudo pacman -S --noconfirm tmux" },
|
|
284
|
+
{ command: "apk", args: ["add", "tmux"], hint: "sudo apk add tmux" }
|
|
285
|
+
]) {
|
|
286
|
+
if (!commandExists(candidate.command, env, executor))
|
|
287
|
+
continue;
|
|
288
|
+
const step = withLinuxPrivilege(candidate.command, candidate.args, env, executor);
|
|
289
|
+
if (step !== null) {
|
|
290
|
+
return { manager: candidate.command, steps: [step], manualHint: candidate.hint };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
function hasExecutable(command, args, executor) {
|
|
296
|
+
try {
|
|
297
|
+
executor.run(command, args);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function commandExists(command, env, executor) {
|
|
305
|
+
if (command.includes("/"))
|
|
306
|
+
return existsSync(command);
|
|
307
|
+
return (env.PATH ?? "").split(delimiter).filter(Boolean)
|
|
308
|
+
.some((entry) => existsSync(join(entry, command)))
|
|
309
|
+
|| hasExecutable(command, ["--version"], executor);
|
|
310
|
+
}
|
|
311
|
+
function withLinuxPrivilege(command, args, env, executor) {
|
|
312
|
+
if (process.getuid?.() === 0)
|
|
313
|
+
return { command, args };
|
|
314
|
+
if (!commandExists("sudo", env, executor))
|
|
315
|
+
return null;
|
|
316
|
+
return { command: "sudo", args: [command, ...args] };
|
|
317
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Replaces one authoritative file without ever exposing a partially-written
|
|
6
|
+
* JSON document. The temporary file lives beside the destination so rename is
|
|
7
|
+
* atomic on the supported local filesystems.
|
|
8
|
+
*/
|
|
9
|
+
export function writeTextFileAtomically(target, content) {
|
|
10
|
+
const directory = dirname(target);
|
|
11
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
12
|
+
const temporary = join(directory, `.${basename(target)}.tmp-${process.pid}-${randomUUID()}`);
|
|
13
|
+
let descriptor = null;
|
|
14
|
+
try {
|
|
15
|
+
descriptor = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
16
|
+
writeFileSync(descriptor, content, "utf8");
|
|
17
|
+
fsyncSync(descriptor);
|
|
18
|
+
closeSync(descriptor);
|
|
19
|
+
descriptor = null;
|
|
20
|
+
renameSync(temporary, target);
|
|
21
|
+
fsyncDirectory(directory);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (descriptor !== null)
|
|
25
|
+
closeSync(descriptor);
|
|
26
|
+
rmSync(temporary, { force: true });
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function fsyncDirectory(directory) {
|
|
31
|
+
const descriptor = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
|
|
32
|
+
try {
|
|
33
|
+
fsyncSync(descriptor);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
closeSync(descriptor);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeTextFileAtomically } from "./durableFile.js";
|
|
4
|
+
/** Version of the on-disk layout (`schema.json`, root `state.json`, and locks). */
|
|
5
|
+
export const CURRENT_STORAGE_LAYOUT_VERSION = 5;
|
|
6
|
+
/** Version of the authoritative aggregate stored in `state.json`. */
|
|
7
|
+
export const CURRENT_AGGREGATE_SCHEMA_VERSION = 1;
|
|
8
|
+
/** @deprecated Use CURRENT_STORAGE_LAYOUT_VERSION for new code. */
|
|
9
|
+
export const CURRENT_STORAGE_SCHEMA_VERSION = CURRENT_STORAGE_LAYOUT_VERSION;
|
|
10
|
+
export const STORAGE_SCHEMA_FILE = "schema.json";
|
|
11
|
+
/**
|
|
12
|
+
* The dispatcher is intentionally present from the first v5 release. There
|
|
13
|
+
* are no v4-to-v5 migrations: v5 is a fresh authoritative storage contract.
|
|
14
|
+
*/
|
|
15
|
+
export const STORAGE_MIGRATIONS = Object.freeze([]);
|
|
16
|
+
/** No historical aggregate is supported by this development release yet. */
|
|
17
|
+
export const AGGREGATE_MIGRATIONS = Object.freeze([]);
|
|
18
|
+
export class StorageSchemaError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
constructor(code, message, options) {
|
|
21
|
+
super(message, options);
|
|
22
|
+
this.name = "StorageSchemaError";
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function inspectStorageSchema(rootDir) {
|
|
27
|
+
const manifestPath = join(rootDir, STORAGE_SCHEMA_FILE);
|
|
28
|
+
const raw = readOptionalText(manifestPath);
|
|
29
|
+
if (raw === null) {
|
|
30
|
+
return {
|
|
31
|
+
status: "uninitialized",
|
|
32
|
+
latestVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
33
|
+
latestLayoutVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
34
|
+
latestAggregateSchemaVersion: CURRENT_AGGREGATE_SCHEMA_VERSION,
|
|
35
|
+
manifestPath
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const manifest = parseStorageManifest(raw);
|
|
40
|
+
if (manifest.storageVersion !== CURRENT_STORAGE_LAYOUT_VERSION) {
|
|
41
|
+
return {
|
|
42
|
+
status: "unsupported",
|
|
43
|
+
incompatibleComponent: "layout",
|
|
44
|
+
direction: manifest.storageVersion < CURRENT_STORAGE_LAYOUT_VERSION ? "older" : "newer",
|
|
45
|
+
currentVersion: manifest.storageVersion,
|
|
46
|
+
latestVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
47
|
+
currentLayoutVersion: manifest.storageVersion,
|
|
48
|
+
latestLayoutVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
49
|
+
currentAggregateSchemaVersion: manifest.aggregateSchemaVersion,
|
|
50
|
+
latestAggregateSchemaVersion: CURRENT_AGGREGATE_SCHEMA_VERSION,
|
|
51
|
+
manifestPath
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (manifest.aggregateSchemaVersion !== CURRENT_AGGREGATE_SCHEMA_VERSION) {
|
|
55
|
+
return {
|
|
56
|
+
status: "unsupported",
|
|
57
|
+
incompatibleComponent: "aggregate",
|
|
58
|
+
direction: manifest.aggregateSchemaVersion < CURRENT_AGGREGATE_SCHEMA_VERSION
|
|
59
|
+
? "older"
|
|
60
|
+
: "newer",
|
|
61
|
+
currentVersion: manifest.aggregateSchemaVersion,
|
|
62
|
+
latestVersion: CURRENT_AGGREGATE_SCHEMA_VERSION,
|
|
63
|
+
currentLayoutVersion: manifest.storageVersion,
|
|
64
|
+
latestLayoutVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
65
|
+
currentAggregateSchemaVersion: manifest.aggregateSchemaVersion,
|
|
66
|
+
latestAggregateSchemaVersion: CURRENT_AGGREGATE_SCHEMA_VERSION,
|
|
67
|
+
manifestPath
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
status: "current",
|
|
72
|
+
currentVersion: manifest.storageVersion,
|
|
73
|
+
latestVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
74
|
+
currentLayoutVersion: manifest.storageVersion,
|
|
75
|
+
latestLayoutVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
76
|
+
currentAggregateSchemaVersion: manifest.aggregateSchemaVersion,
|
|
77
|
+
latestAggregateSchemaVersion: CURRENT_AGGREGATE_SCHEMA_VERSION,
|
|
78
|
+
activeGeneration: manifest.activeGeneration,
|
|
79
|
+
manifestPath
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
status: "invalid",
|
|
85
|
+
latestVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
86
|
+
latestLayoutVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
87
|
+
latestAggregateSchemaVersion: CURRENT_AGGREGATE_SCHEMA_VERSION,
|
|
88
|
+
manifestPath,
|
|
89
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function ensureStorageSchema(rootDir, now = new Date()) {
|
|
94
|
+
const state = inspectStorageSchema(rootDir);
|
|
95
|
+
if (state.status === "uninitialized") {
|
|
96
|
+
writeStorageManifest(rootDir, now);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
requireInspectedSchema(state);
|
|
100
|
+
}
|
|
101
|
+
export function requireStorageSchema(rootDir) {
|
|
102
|
+
requireInspectedSchema(inspectStorageSchema(rootDir));
|
|
103
|
+
}
|
|
104
|
+
export function dispatchStorageMigrations(rootDir, fromVersion, targetVersion = CURRENT_STORAGE_LAYOUT_VERSION, migrations = STORAGE_MIGRATIONS) {
|
|
105
|
+
assertLayoutVersion(fromVersion, "Migration source");
|
|
106
|
+
assertLayoutVersion(targetVersion, "Migration target");
|
|
107
|
+
if (fromVersion > targetVersion) {
|
|
108
|
+
throw unsupportedVersion(fromVersion, targetVersion, "layout");
|
|
109
|
+
}
|
|
110
|
+
// Build and validate the complete plan before invoking any filesystem mutation.
|
|
111
|
+
const bySource = new Map();
|
|
112
|
+
for (const migration of migrations) {
|
|
113
|
+
assertLayoutVersion(migration.fromVersion, "Migration fromVersion");
|
|
114
|
+
assertLayoutVersion(migration.toVersion, "Migration toVersion");
|
|
115
|
+
if (migration.toVersion !== migration.fromVersion + 1) {
|
|
116
|
+
throw invalidMigration(`Migration ${migration.fromVersion}->${migration.toVersion} must advance exactly one layout version.`);
|
|
117
|
+
}
|
|
118
|
+
if (bySource.has(migration.fromVersion)) {
|
|
119
|
+
throw invalidMigration(`Duplicate migration from layout version ${migration.fromVersion}.`);
|
|
120
|
+
}
|
|
121
|
+
bySource.set(migration.fromVersion, migration);
|
|
122
|
+
}
|
|
123
|
+
const plan = [];
|
|
124
|
+
for (let current = fromVersion; current < targetVersion; current += 1) {
|
|
125
|
+
const migration = bySource.get(current);
|
|
126
|
+
if (migration === undefined || migration.toVersion !== current + 1) {
|
|
127
|
+
throw unsupportedVersion(current, targetVersion, "layout");
|
|
128
|
+
}
|
|
129
|
+
plan.push(migration);
|
|
130
|
+
}
|
|
131
|
+
for (const migration of plan)
|
|
132
|
+
migration.migrate(rootDir);
|
|
133
|
+
}
|
|
134
|
+
export function dispatchAggregateMigrations(state, fromVersion, targetVersion = CURRENT_AGGREGATE_SCHEMA_VERSION, migrations = AGGREGATE_MIGRATIONS) {
|
|
135
|
+
assertLayoutVersion(fromVersion, "Aggregate migration source");
|
|
136
|
+
assertLayoutVersion(targetVersion, "Aggregate migration target");
|
|
137
|
+
if (fromVersion > targetVersion) {
|
|
138
|
+
throw unsupportedVersion(fromVersion, targetVersion, "aggregate");
|
|
139
|
+
}
|
|
140
|
+
const bySource = new Map();
|
|
141
|
+
for (const migration of migrations) {
|
|
142
|
+
assertLayoutVersion(migration.fromVersion, "Aggregate migration fromVersion");
|
|
143
|
+
assertLayoutVersion(migration.toVersion, "Aggregate migration toVersion");
|
|
144
|
+
if (migration.toVersion !== migration.fromVersion + 1) {
|
|
145
|
+
throw invalidMigration(`Aggregate migration ${migration.fromVersion}->${migration.toVersion} must advance exactly one schema version.`);
|
|
146
|
+
}
|
|
147
|
+
if (bySource.has(migration.fromVersion)) {
|
|
148
|
+
throw invalidMigration(`Duplicate migration from aggregate schema version ${migration.fromVersion}.`);
|
|
149
|
+
}
|
|
150
|
+
bySource.set(migration.fromVersion, migration);
|
|
151
|
+
}
|
|
152
|
+
const plan = [];
|
|
153
|
+
for (let current = fromVersion; current < targetVersion; current += 1) {
|
|
154
|
+
const migration = bySource.get(current);
|
|
155
|
+
if (migration === undefined || migration.toVersion !== current + 1) {
|
|
156
|
+
throw unsupportedVersion(current, targetVersion, "aggregate");
|
|
157
|
+
}
|
|
158
|
+
plan.push(migration);
|
|
159
|
+
}
|
|
160
|
+
return plan.reduce((current, migration) => migration.migrate(current), state);
|
|
161
|
+
}
|
|
162
|
+
function writeStorageManifest(rootDir, now) {
|
|
163
|
+
const manifest = {
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
storageVersion: CURRENT_STORAGE_LAYOUT_VERSION,
|
|
166
|
+
aggregateSchemaVersion: CURRENT_AGGREGATE_SCHEMA_VERSION,
|
|
167
|
+
activeGeneration: null,
|
|
168
|
+
updatedAt: now.toISOString()
|
|
169
|
+
};
|
|
170
|
+
writeTextFileAtomically(join(rootDir, STORAGE_SCHEMA_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
171
|
+
}
|
|
172
|
+
function requireInspectedSchema(state) {
|
|
173
|
+
switch (state.status) {
|
|
174
|
+
case "current":
|
|
175
|
+
return;
|
|
176
|
+
case "uninitialized":
|
|
177
|
+
throw new StorageSchemaError("STORAGE_UNINITIALIZED", "Yui storage is not initialized. Run `yui setup`.");
|
|
178
|
+
case "invalid":
|
|
179
|
+
throw new StorageSchemaError("STORAGE_SCHEMA_INVALID", `Invalid storage schema manifest at ${state.manifestPath}: ${state.detail}`);
|
|
180
|
+
case "unsupported":
|
|
181
|
+
throw unsupportedVersion(state.currentVersion, state.latestVersion, state.incompatibleComponent);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function unsupportedVersion(current, required, component) {
|
|
185
|
+
const label = component === "layout" ? "Storage layout" : "Aggregate schema";
|
|
186
|
+
if (current < required) {
|
|
187
|
+
return new StorageSchemaError("STORAGE_SCHEMA_UNSUPPORTED", `${label} ${current} is older than required ${component} version ${required}; no migration is available in this Yui release.`);
|
|
188
|
+
}
|
|
189
|
+
return new StorageSchemaError("STORAGE_SCHEMA_UNSUPPORTED", `${label} ${current} is newer than supported ${component} version ${required}; use a newer Yui release.`);
|
|
190
|
+
}
|
|
191
|
+
function parseStorageManifest(raw) {
|
|
192
|
+
const value = parseJsonObject(raw, "Storage schema manifest");
|
|
193
|
+
assertKeys(value, ["schemaVersion", "storageVersion", "aggregateSchemaVersion", "updatedAt"], ["activeGeneration"], "Storage schema manifest");
|
|
194
|
+
if (value.schemaVersion !== 1)
|
|
195
|
+
throw new Error("schemaVersion must be 1");
|
|
196
|
+
if (!Number.isInteger(value.storageVersion) || value.storageVersion < 1) {
|
|
197
|
+
throw new Error("storageVersion must be a positive integer");
|
|
198
|
+
}
|
|
199
|
+
const aggregateSchemaVersion = value.aggregateSchemaVersion;
|
|
200
|
+
if (!Number.isInteger(aggregateSchemaVersion) || aggregateSchemaVersion < 1) {
|
|
201
|
+
throw new Error("aggregateSchemaVersion must be a positive integer");
|
|
202
|
+
}
|
|
203
|
+
const activeGeneration = value.activeGeneration ?? null;
|
|
204
|
+
if (activeGeneration !== null) {
|
|
205
|
+
throw new Error(`activeGeneration is not supported by storage layout ${String(value.storageVersion)}`);
|
|
206
|
+
}
|
|
207
|
+
if (typeof value.updatedAt !== "string" || !Number.isFinite(Date.parse(value.updatedAt))) {
|
|
208
|
+
throw new Error("updatedAt must be an ISO timestamp");
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
schemaVersion: 1,
|
|
212
|
+
storageVersion: value.storageVersion,
|
|
213
|
+
aggregateSchemaVersion: aggregateSchemaVersion,
|
|
214
|
+
activeGeneration,
|
|
215
|
+
updatedAt: value.updatedAt
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function parseJsonObject(raw, label) {
|
|
219
|
+
let value;
|
|
220
|
+
try {
|
|
221
|
+
value = JSON.parse(raw);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
throw new Error(`${label} is not valid JSON`, { cause: error });
|
|
225
|
+
}
|
|
226
|
+
if (!isRecord(value))
|
|
227
|
+
throw new Error(`${label} must be an object`);
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
function assertKeys(value, requiredKeys, optionalKeys, label) {
|
|
231
|
+
const expected = new Set([...requiredKeys, ...optionalKeys]);
|
|
232
|
+
const unknown = Object.keys(value).filter((key) => !expected.has(key));
|
|
233
|
+
const missing = requiredKeys.filter((key) => !Object.hasOwn(value, key));
|
|
234
|
+
if (unknown.length > 0)
|
|
235
|
+
throw new Error(`${label} has unknown field: ${unknown[0]}`);
|
|
236
|
+
if (missing.length > 0)
|
|
237
|
+
throw new Error(`${label} is missing field: ${missing[0]}`);
|
|
238
|
+
}
|
|
239
|
+
function assertLayoutVersion(version, label) {
|
|
240
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
241
|
+
throw invalidMigration(`${label} must be a positive integer.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function invalidMigration(message) {
|
|
245
|
+
return new StorageSchemaError("STORAGE_MIGRATION_INVALID", message);
|
|
246
|
+
}
|
|
247
|
+
function readOptionalText(path) {
|
|
248
|
+
try {
|
|
249
|
+
return readFileSync(path, "utf8");
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
253
|
+
return null;
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function isRecord(value) {
|
|
258
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
259
|
+
}
|