vde-layout 0.0.1 → 0.0.3
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/README.md +17 -0
- package/bin/vde-layout +9 -1
- package/dist/index.js +1459 -22
- package/dist/index.js.map +1 -1
- package/package.json +22 -11
- package/dist/cli.d.ts +0 -27
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -138
- package/dist/cli.js.map +0 -1
- package/dist/config/loader.d.ts +0 -14
- package/dist/config/loader.d.ts.map +0 -1
- package/dist/config/loader.js +0 -117
- package/dist/config/loader.js.map +0 -1
- package/dist/config/validator.d.ts +0 -3
- package/dist/config/validator.d.ts.map +0 -1
- package/dist/config/validator.js +0 -168
- package/dist/config/validator.js.map +0 -1
- package/dist/executor/dry-run-executor.d.ts +0 -16
- package/dist/executor/dry-run-executor.d.ts.map +0 -1
- package/dist/executor/dry-run-executor.js +0 -45
- package/dist/executor/dry-run-executor.js.map +0 -1
- package/dist/executor/index.d.ts +0 -6
- package/dist/executor/index.d.ts.map +0 -1
- package/dist/executor/index.js +0 -10
- package/dist/executor/index.js.map +0 -1
- package/dist/executor/mock-executor.d.ts +0 -21
- package/dist/executor/mock-executor.d.ts.map +0 -1
- package/dist/executor/mock-executor.js +0 -73
- package/dist/executor/mock-executor.js.map +0 -1
- package/dist/executor/real-executor.d.ts +0 -16
- package/dist/executor/real-executor.d.ts.map +0 -1
- package/dist/executor/real-executor.js +0 -58
- package/dist/executor/real-executor.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/interfaces/command-executor.d.ts +0 -7
- package/dist/interfaces/command-executor.d.ts.map +0 -1
- package/dist/interfaces/command-executor.js +0 -3
- package/dist/interfaces/command-executor.js.map +0 -1
- package/dist/interfaces/index.d.ts +0 -31
- package/dist/interfaces/index.d.ts.map +0 -1
- package/dist/interfaces/index.js +0 -3
- package/dist/interfaces/index.js.map +0 -1
- package/dist/layout/engine.d.ts +0 -18
- package/dist/layout/engine.d.ts.map +0 -1
- package/dist/layout/engine.js +0 -174
- package/dist/layout/engine.js.map +0 -1
- package/dist/layout/preset.d.ts +0 -13
- package/dist/layout/preset.d.ts.map +0 -1
- package/dist/layout/preset.js +0 -55
- package/dist/layout/preset.js.map +0 -1
- package/dist/models/schema.d.ts +0 -144
- package/dist/models/schema.d.ts.map +0 -1
- package/dist/models/schema.js +0 -95
- package/dist/models/schema.js.map +0 -1
- package/dist/models/types.d.ts +0 -34
- package/dist/models/types.d.ts.map +0 -1
- package/dist/models/types.js +0 -16
- package/dist/models/types.js.map +0 -1
- package/dist/tmux/commands.d.ts +0 -14
- package/dist/tmux/commands.d.ts.map +0 -1
- package/dist/tmux/commands.js +0 -59
- package/dist/tmux/commands.js.map +0 -1
- package/dist/tmux/executor.d.ts +0 -20
- package/dist/tmux/executor.d.ts.map +0 -1
- package/dist/tmux/executor.js +0 -64
- package/dist/tmux/executor.js.map +0 -1
- package/dist/utils/errors.d.ts +0 -36
- package/dist/utils/errors.d.ts.map +0 -1
- package/dist/utils/errors.js +0 -131
- package/dist/utils/errors.js.map +0 -1
- package/dist/utils/logger.d.ts +0 -25
- package/dist/utils/logger.d.ts.map +0 -1
- package/dist/utils/logger.js +0 -67
- package/dist/utils/logger.js.map +0 -1
- package/dist/utils/ratio.d.ts +0 -3
- package/dist/utils/ratio.d.ts.map +0 -1
- package/dist/utils/ratio.js +0 -44
- package/dist/utils/ratio.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,24 +1,1461 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import * as YAML from "yaml";
|
|
6
|
+
import { parse, stringify } from "yaml";
|
|
7
|
+
import fs from "fs-extra";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { execa } from "execa";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
//#region src/utils/errors.ts
|
|
15
|
+
const ErrorCodes = {
|
|
16
|
+
CONFIG_NOT_FOUND: "CONFIG_NOT_FOUND",
|
|
17
|
+
CONFIG_PARSE_ERROR: "CONFIG_PARSE_ERROR",
|
|
18
|
+
CONFIG_PERMISSION_ERROR: "CONFIG_PERMISSION_ERROR",
|
|
19
|
+
INVALID_PRESET: "INVALID_PRESET",
|
|
20
|
+
PRESET_NOT_FOUND: "PRESET_NOT_FOUND",
|
|
21
|
+
INVALID_LAYOUT: "INVALID_LAYOUT",
|
|
22
|
+
INVALID_PANE: "INVALID_PANE",
|
|
23
|
+
TMUX_NOT_RUNNING: "TMUX_NOT_RUNNING",
|
|
24
|
+
TMUX_COMMAND_FAILED: "TMUX_COMMAND_FAILED",
|
|
25
|
+
NOT_IN_TMUX_SESSION: "NOT_IN_TMUX_SESSION",
|
|
26
|
+
NOT_IN_TMUX: "NOT_IN_TMUX",
|
|
27
|
+
TMUX_NOT_FOUND: "TMUX_NOT_FOUND",
|
|
28
|
+
TMUX_NOT_INSTALLED: "TMUX_NOT_INSTALLED",
|
|
29
|
+
UNSUPPORTED_TMUX_VERSION: "UNSUPPORTED_TMUX_VERSION"
|
|
30
|
+
};
|
|
31
|
+
const createBaseError = (name, message, code, details = {}) => {
|
|
32
|
+
const error = new Error(message);
|
|
33
|
+
error.name = name;
|
|
34
|
+
error.code = code;
|
|
35
|
+
error.details = details;
|
|
36
|
+
return error;
|
|
37
|
+
};
|
|
38
|
+
const createConfigError = (message, code, details = {}) => {
|
|
39
|
+
return createBaseError("ConfigError", message, code, details);
|
|
40
|
+
};
|
|
41
|
+
const createValidationError = (message, code, details = {}) => {
|
|
42
|
+
return createBaseError("ValidationError", message, code, details);
|
|
43
|
+
};
|
|
44
|
+
const createTmuxError = (message, code, details = {}) => {
|
|
45
|
+
return createBaseError("TmuxError", message, code, details);
|
|
46
|
+
};
|
|
47
|
+
const isVDELayoutError = (error) => {
|
|
48
|
+
if (typeof error !== "object" || error === null) return false;
|
|
49
|
+
if (!("code" in error)) return false;
|
|
50
|
+
const { code } = error;
|
|
51
|
+
if (typeof code !== "string") return false;
|
|
52
|
+
if (!("details" in error)) return false;
|
|
53
|
+
return true;
|
|
54
|
+
};
|
|
55
|
+
const formatters = {
|
|
56
|
+
[ErrorCodes.CONFIG_NOT_FOUND]: (error) => {
|
|
57
|
+
const searchPaths = error.details.searchPaths;
|
|
58
|
+
if (!Array.isArray(searchPaths)) return "";
|
|
59
|
+
const lines = ["", "Searched in the following locations:"];
|
|
60
|
+
searchPaths.forEach((location) => lines.push(` - ${location}`));
|
|
61
|
+
lines.push("", "To create a configuration file, run:");
|
|
62
|
+
lines.push(" mkdir -p ~/.config/vde");
|
|
63
|
+
lines.push(" echo \"presets: {}\" > ~/.config/vde/layout.yml");
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
},
|
|
66
|
+
[ErrorCodes.NOT_IN_TMUX_SESSION]: () => {
|
|
67
|
+
return "\nThis command must be run inside a tmux session.\nStart tmux first with: tmux\n";
|
|
68
|
+
},
|
|
69
|
+
[ErrorCodes.TMUX_NOT_INSTALLED]: () => {
|
|
70
|
+
return "\ntmux is required but not installed.\nInstall tmux using your package manager:\n - macOS: brew install tmux\n - Ubuntu/Debian: sudo apt-get install tmux\n - Fedora: sudo dnf install tmux\n";
|
|
71
|
+
},
|
|
72
|
+
[ErrorCodes.UNSUPPORTED_TMUX_VERSION]: (error) => {
|
|
73
|
+
const requiredVersion = error.details.requiredVersion;
|
|
74
|
+
if (typeof requiredVersion !== "string") return "";
|
|
75
|
+
return `\nRequired tmux version: ${requiredVersion} or higher\n`;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/models/schema.ts
|
|
81
|
+
const TerminalPaneSchema = z.object({
|
|
82
|
+
name: z.string().min(1),
|
|
83
|
+
command: z.string().optional(),
|
|
84
|
+
cwd: z.string().optional(),
|
|
85
|
+
env: z.record(z.string()).optional(),
|
|
86
|
+
delay: z.number().int().positive().optional(),
|
|
87
|
+
title: z.string().optional(),
|
|
88
|
+
focus: z.boolean().optional()
|
|
89
|
+
}).strict();
|
|
90
|
+
const SplitPaneSchema = z.lazy(() => z.object({
|
|
91
|
+
type: z.enum(["horizontal", "vertical"]),
|
|
92
|
+
ratio: z.array(z.number().positive()).min(1),
|
|
93
|
+
panes: z.array(PaneSchema).min(1)
|
|
94
|
+
}).strict().refine((data) => data.ratio.length === data.panes.length, { message: "Number of elements in ratio array does not match number of elements in panes array" }));
|
|
95
|
+
const PaneSchema = z.lazy(() => z.union([SplitPaneSchema, TerminalPaneSchema]));
|
|
96
|
+
const LayoutSchema = z.object({
|
|
97
|
+
type: z.enum(["horizontal", "vertical"]),
|
|
98
|
+
ratio: z.array(z.number().positive()).min(1),
|
|
99
|
+
panes: z.array(PaneSchema).min(1)
|
|
100
|
+
}).refine((data) => data.ratio.length === data.panes.length, { message: "Number of elements in ratio array does not match number of elements in panes array" });
|
|
101
|
+
const PresetSchema = z.object({
|
|
102
|
+
name: z.string().min(1),
|
|
103
|
+
description: z.string().optional(),
|
|
104
|
+
layout: LayoutSchema.optional(),
|
|
105
|
+
command: z.string().optional()
|
|
106
|
+
});
|
|
107
|
+
const ConfigSchema = z.object({ presets: z.record(PresetSchema) });
|
|
108
|
+
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/config/validator.ts
|
|
111
|
+
/**
|
|
112
|
+
* Parse YAML text into an object
|
|
113
|
+
* @param yamlText - YAML text to parse
|
|
114
|
+
* @returns Parsed object
|
|
115
|
+
* @throws {ValidationError} When YAML parsing fails
|
|
116
|
+
*/
|
|
117
|
+
const parseYAML = (yamlText) => {
|
|
118
|
+
if (!yamlText || typeof yamlText !== "string") throw createValidationError("YAML text not provided", ErrorCodes.CONFIG_PARSE_ERROR, { received: typeof yamlText });
|
|
119
|
+
try {
|
|
120
|
+
return YAML.parse(yamlText);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw createValidationError("Failed to parse YAML", ErrorCodes.CONFIG_PARSE_ERROR, {
|
|
123
|
+
parseError: error instanceof Error ? error.message : String(error),
|
|
124
|
+
yamlSnippet: yamlText.substring(0, 200)
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Validate basic configuration structure
|
|
130
|
+
* @param parsed - Parsed YAML object
|
|
131
|
+
* @throws {ValidationError} When structure is invalid
|
|
132
|
+
*/
|
|
133
|
+
const validateConfigStructure = (parsed) => {
|
|
134
|
+
if (parsed === null || parsed === void 0 || typeof parsed !== "object") throw createValidationError("YAML is empty or invalid format", ErrorCodes.CONFIG_PARSE_ERROR, { parsed });
|
|
135
|
+
const parsedObj = parsed;
|
|
136
|
+
if (!("presets" in parsedObj) || parsedObj.presets === void 0 || parsedObj.presets === null) throw createValidationError("presets field is required", ErrorCodes.INVALID_PRESET, { availableFields: Object.keys(parsedObj) });
|
|
137
|
+
const presetsObj = parsedObj.presets;
|
|
138
|
+
if (typeof presetsObj !== "object" || presetsObj === null || Object.keys(presetsObj).length === 0) throw createValidationError("At least one preset is required", ErrorCodes.INVALID_PRESET, { presets: presetsObj });
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Format Zod validation errors into user-friendly messages
|
|
142
|
+
* @param error - Zod validation error
|
|
143
|
+
* @returns Formatted error issues
|
|
144
|
+
*/
|
|
145
|
+
const formatZodErrors = (error) => {
|
|
146
|
+
return error.issues.map((issue) => {
|
|
147
|
+
const path$1 = issue.path.join(".");
|
|
148
|
+
let message = issue.message;
|
|
149
|
+
if (issue.code === "invalid_type") {
|
|
150
|
+
if (issue.path.includes("command") && issue.expected === "string") message = "command field must be a string";
|
|
151
|
+
else if (issue.path.includes("workingDirectory") && issue.expected === "string") message = "workingDirectory field must be a string";
|
|
152
|
+
else if (issue.received === "number" && issue.expected === "string") message = `${path$1} must be a string`;
|
|
153
|
+
else if (issue.received === "array" && issue.expected === "string") message = `${path$1} must be a string`;
|
|
154
|
+
} else if (issue.code === "invalid_union") {
|
|
155
|
+
const unionIssue = issue;
|
|
156
|
+
if (unionIssue.unionErrors !== void 0) if (unionIssue.unionErrors.find((e) => e.issues?.some((i) => i.path.includes("command") && i.code === "invalid_type") === true) !== void 0) message = "command field is required";
|
|
157
|
+
else if (unionIssue.unionErrors.find((e) => e.issues?.some((i) => i.path.includes("panes") && i.code === "invalid_type") === true) !== void 0) message = "panes field is required";
|
|
158
|
+
else message = "Pane type must be \"terminal\" or \"split\"";
|
|
159
|
+
else message = "Pane type must be \"terminal\" or \"split\"";
|
|
160
|
+
} else if (issue.code === "invalid_literal") {
|
|
161
|
+
if (issue.path.includes("direction")) message = "direction must be \"horizontal\" or \"vertical\"";
|
|
162
|
+
} else if (issue.message.includes("required")) message = issue.message;
|
|
163
|
+
else if (issue.code === "custom" && issue.message.includes("ratio array")) message = issue.message;
|
|
164
|
+
else if (issue.code === "too_small" && issue.message.includes("Array must contain at least")) if (path$1.includes("panes")) message = "panes array must contain at least 2 elements";
|
|
165
|
+
else if (path$1.includes("ratio")) message = "ratio array must contain at least 2 elements";
|
|
166
|
+
else message = issue.message;
|
|
167
|
+
return {
|
|
168
|
+
path: path$1,
|
|
169
|
+
message,
|
|
170
|
+
code: issue.code
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Validates YAML text and converts it to a type-safe Config object
|
|
176
|
+
* @param yamlText - YAML text to validate
|
|
177
|
+
* @returns Validated Config object
|
|
178
|
+
* @throws {ValidationError} When YAML is invalid
|
|
179
|
+
*/
|
|
180
|
+
const validateYAML = (yamlText) => {
|
|
181
|
+
const parsed = parseYAML(yamlText);
|
|
182
|
+
validateConfigStructure(parsed);
|
|
183
|
+
try {
|
|
184
|
+
return ConfigSchema.parse(parsed);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof z.ZodError) {
|
|
187
|
+
const issues = formatZodErrors(error);
|
|
188
|
+
const primaryMessage = issues.length > 0 && issues[0] ? issues[0].message : "Configuration validation failed";
|
|
189
|
+
throw createValidationError(primaryMessage, ErrorCodes.CONFIG_PARSE_ERROR, {
|
|
190
|
+
issues,
|
|
191
|
+
rawErrors: error.issues
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (isVDELayoutError(error) && error.name === "ValidationError") throw error;
|
|
195
|
+
throw createValidationError("Unexpected validation error occurred", ErrorCodes.CONFIG_PARSE_ERROR, { error: error instanceof Error ? error.message : String(error) });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/config/loader.ts
|
|
201
|
+
const createConfigLoader = (options = {}) => {
|
|
202
|
+
const explicitConfigPaths = options.configPaths;
|
|
203
|
+
const computeCachedSearchPaths = () => {
|
|
204
|
+
if (explicitConfigPaths && explicitConfigPaths.length > 0) return [...explicitConfigPaths];
|
|
205
|
+
const candidates = [];
|
|
206
|
+
const projectCandidate = findProjectConfigCandidate();
|
|
207
|
+
if (projectCandidate !== null) candidates.push(projectCandidate);
|
|
208
|
+
candidates.push(...buildDefaultSearchPaths());
|
|
209
|
+
return [...new Set(candidates)];
|
|
210
|
+
};
|
|
211
|
+
const loadConfig = async () => {
|
|
212
|
+
if (explicitConfigPaths && explicitConfigPaths.length > 0) {
|
|
213
|
+
const filePath = await findFirstExisting(explicitConfigPaths);
|
|
214
|
+
if (filePath === null) throw createConfigError("Configuration file not found", ErrorCodes.CONFIG_NOT_FOUND, { searchPaths: explicitConfigPaths });
|
|
215
|
+
const content = await safeReadFile(filePath);
|
|
216
|
+
return validateYAML(content);
|
|
217
|
+
}
|
|
218
|
+
const searchPaths = computeCachedSearchPaths();
|
|
219
|
+
const existingPaths = await filterExistingPaths(searchPaths);
|
|
220
|
+
if (existingPaths.length === 0) throw createConfigError("Configuration file not found", ErrorCodes.CONFIG_NOT_FOUND, { searchPaths });
|
|
221
|
+
const projectPath = findProjectConfigCandidate();
|
|
222
|
+
const globalPaths = existingPaths.filter((filePath) => filePath !== projectPath);
|
|
223
|
+
let mergedConfig = { presets: {} };
|
|
224
|
+
for (const globalPath of globalPaths) {
|
|
225
|
+
const content = await safeReadFile(globalPath);
|
|
226
|
+
const config = validateYAML(content);
|
|
227
|
+
mergedConfig = mergeConfigs(mergedConfig, config);
|
|
228
|
+
}
|
|
229
|
+
if (projectPath !== null && await fs.pathExists(projectPath)) {
|
|
230
|
+
const content = await safeReadFile(projectPath);
|
|
231
|
+
const config = validateYAML(content);
|
|
232
|
+
mergedConfig = mergeConfigs(mergedConfig, config);
|
|
233
|
+
}
|
|
234
|
+
return mergedConfig;
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
loadYAML: async () => {
|
|
238
|
+
const config = await loadConfig();
|
|
239
|
+
return YAML.stringify(config);
|
|
240
|
+
},
|
|
241
|
+
loadConfig,
|
|
242
|
+
findConfigFile: async () => {
|
|
243
|
+
const searchPaths = explicitConfigPaths && explicitConfigPaths.length > 0 ? [...explicitConfigPaths] : computeCachedSearchPaths();
|
|
244
|
+
for (const searchPath of searchPaths) if (await fs.pathExists(searchPath)) return searchPath;
|
|
245
|
+
return null;
|
|
246
|
+
},
|
|
247
|
+
getSearchPaths: () => computeCachedSearchPaths()
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
const buildDefaultSearchPaths = () => {
|
|
251
|
+
const paths = [];
|
|
252
|
+
const vdeConfigPath = process.env.VDE_CONFIG_PATH;
|
|
253
|
+
if (vdeConfigPath !== void 0) paths.push(path.join(vdeConfigPath, "layout.yml"));
|
|
254
|
+
const homeDir = process.env.HOME ?? os.homedir();
|
|
255
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
|
|
256
|
+
paths.push(path.join(xdgConfigHome, "vde", "layout.yml"));
|
|
257
|
+
return [...new Set(paths)];
|
|
258
|
+
};
|
|
259
|
+
const findProjectConfigCandidate = () => {
|
|
260
|
+
let currentDir = process.cwd();
|
|
261
|
+
const { root } = path.parse(currentDir);
|
|
262
|
+
while (true) {
|
|
263
|
+
const candidate = path.join(currentDir, ".vde", "layout.yml");
|
|
264
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
265
|
+
if (currentDir === root) break;
|
|
266
|
+
const parent = path.dirname(currentDir);
|
|
267
|
+
if (parent === currentDir) break;
|
|
268
|
+
currentDir = parent;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
};
|
|
272
|
+
const findFirstExisting = async (paths) => {
|
|
273
|
+
for (const candidate of paths) if (await fs.pathExists(candidate)) return candidate;
|
|
274
|
+
return null;
|
|
275
|
+
};
|
|
276
|
+
const filterExistingPaths = async (paths) => {
|
|
277
|
+
const existing = [];
|
|
278
|
+
for (const candidate of paths) if (await fs.pathExists(candidate)) existing.push(candidate);
|
|
279
|
+
return existing;
|
|
280
|
+
};
|
|
281
|
+
const safeReadFile = async (filePath) => {
|
|
282
|
+
try {
|
|
283
|
+
return await fs.readFile(filePath, "utf8");
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
286
|
+
throw createConfigError(`Failed to read configuration file`, ErrorCodes.CONFIG_PERMISSION_ERROR, {
|
|
287
|
+
filePath,
|
|
288
|
+
error: errorMessage
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const mergeConfigs = (base, override) => {
|
|
293
|
+
return { presets: {
|
|
294
|
+
...base.presets,
|
|
295
|
+
...override.presets
|
|
296
|
+
} };
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/layout/preset.ts
|
|
301
|
+
const createState = (options = {}) => {
|
|
302
|
+
let loaderOptions = options;
|
|
303
|
+
let cachedConfig = null;
|
|
304
|
+
const setConfigPath = (filePath) => {
|
|
305
|
+
loaderOptions = { configPaths: [filePath] };
|
|
306
|
+
cachedConfig = null;
|
|
307
|
+
};
|
|
308
|
+
const loadConfig = async () => {
|
|
309
|
+
cachedConfig = await createConfigLoader(loaderOptions).loadConfig();
|
|
310
|
+
};
|
|
311
|
+
const ensureConfig = () => {
|
|
312
|
+
if (cachedConfig === null) throw createConfigError("Configuration not loaded", ErrorCodes.CONFIG_NOT_FOUND);
|
|
313
|
+
return cachedConfig;
|
|
314
|
+
};
|
|
315
|
+
const getPreset = (name) => {
|
|
316
|
+
const config = ensureConfig();
|
|
317
|
+
const preset = config.presets[name];
|
|
318
|
+
if (preset === void 0) throw createConfigError(`Preset "${name}" not found`, ErrorCodes.PRESET_NOT_FOUND, { availablePresets: Object.keys(config.presets) });
|
|
319
|
+
return preset;
|
|
320
|
+
};
|
|
321
|
+
const listPresets = () => {
|
|
322
|
+
if (cachedConfig === null) return [];
|
|
323
|
+
return Object.entries(cachedConfig.presets).map(([key, preset]) => ({
|
|
324
|
+
key,
|
|
325
|
+
name: preset.name,
|
|
326
|
+
description: preset.description
|
|
327
|
+
}));
|
|
328
|
+
};
|
|
329
|
+
const getDefaultPreset = () => {
|
|
330
|
+
const config = ensureConfig();
|
|
331
|
+
if (config.presets.default !== void 0) return config.presets.default;
|
|
332
|
+
const firstKey = Object.keys(config.presets)[0];
|
|
333
|
+
if (typeof firstKey !== "string" || firstKey.length === 0) throw createConfigError("No presets defined", ErrorCodes.PRESET_NOT_FOUND);
|
|
334
|
+
return config.presets[firstKey];
|
|
335
|
+
};
|
|
336
|
+
return {
|
|
337
|
+
setConfigPath,
|
|
338
|
+
loadConfig,
|
|
339
|
+
getPreset,
|
|
340
|
+
listPresets,
|
|
341
|
+
getDefaultPreset
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
const createPresetManager = (options = {}) => {
|
|
345
|
+
const state = createState(options);
|
|
346
|
+
return {
|
|
347
|
+
setConfigPath: state.setConfigPath,
|
|
348
|
+
loadConfig: state.loadConfig,
|
|
349
|
+
getPreset: state.getPreset,
|
|
350
|
+
listPresets: state.listPresets,
|
|
351
|
+
getDefaultPreset: state.getDefaultPreset
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/utils/logger.ts
|
|
357
|
+
let LogLevel = /* @__PURE__ */ function(LogLevel$1) {
|
|
358
|
+
LogLevel$1[LogLevel$1["ERROR"] = 0] = "ERROR";
|
|
359
|
+
LogLevel$1[LogLevel$1["WARN"] = 1] = "WARN";
|
|
360
|
+
LogLevel$1[LogLevel$1["INFO"] = 2] = "INFO";
|
|
361
|
+
LogLevel$1[LogLevel$1["DEBUG"] = 3] = "DEBUG";
|
|
362
|
+
return LogLevel$1;
|
|
363
|
+
}({});
|
|
364
|
+
const resolveDefaultLogLevel = () => {
|
|
365
|
+
if (process.env.VDE_DEBUG === "true") return LogLevel.DEBUG;
|
|
366
|
+
if (process.env.VDE_VERBOSE === "true") return LogLevel.INFO;
|
|
367
|
+
return LogLevel.WARN;
|
|
368
|
+
};
|
|
369
|
+
const formatMessage = (prefix, message) => {
|
|
370
|
+
return prefix ? `${prefix} ${message}` : message;
|
|
371
|
+
};
|
|
372
|
+
const createLogger = (options = {}) => {
|
|
373
|
+
const level = options.level ?? resolveDefaultLogLevel();
|
|
374
|
+
const prefix = options.prefix ?? "";
|
|
375
|
+
const build = (nextPrefix, nextLevel) => {
|
|
376
|
+
const resolvedPrefix = nextPrefix;
|
|
377
|
+
return {
|
|
378
|
+
level: nextLevel,
|
|
379
|
+
prefix: resolvedPrefix,
|
|
380
|
+
error(message, error) {
|
|
381
|
+
if (nextLevel >= LogLevel.ERROR) {
|
|
382
|
+
console.error(chalk.red(formatMessage(resolvedPrefix, `Error: ${message}`)));
|
|
383
|
+
if (error && process.env.VDE_DEBUG === "true") console.error(chalk.gray(error.stack));
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
warn(message) {
|
|
387
|
+
if (nextLevel >= LogLevel.WARN) console.warn(chalk.yellow(formatMessage(resolvedPrefix, message)));
|
|
388
|
+
},
|
|
389
|
+
info(message) {
|
|
390
|
+
if (nextLevel >= LogLevel.INFO) console.log(formatMessage(resolvedPrefix, message));
|
|
391
|
+
},
|
|
392
|
+
debug(message) {
|
|
393
|
+
if (nextLevel >= LogLevel.DEBUG) console.log(chalk.gray(formatMessage(resolvedPrefix, `[DEBUG] ${message}`)));
|
|
394
|
+
},
|
|
395
|
+
success(message) {
|
|
396
|
+
console.log(chalk.green(formatMessage(resolvedPrefix, message)));
|
|
397
|
+
},
|
|
398
|
+
createChild(suffix) {
|
|
399
|
+
const childPrefix = resolvedPrefix ? `${resolvedPrefix} ${suffix}` : suffix;
|
|
400
|
+
return build(childPrefix, nextLevel);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
return build(prefix, level);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/executor/real-executor.ts
|
|
409
|
+
const parseCommand$1 = (commandOrArgs) => {
|
|
410
|
+
return typeof commandOrArgs === "string" ? commandOrArgs.split(" ").filter((segment) => segment.length > 0).slice(1) : commandOrArgs;
|
|
411
|
+
};
|
|
412
|
+
const toCommandString$1 = (args) => {
|
|
413
|
+
return ["tmux", ...args].join(" ");
|
|
414
|
+
};
|
|
415
|
+
const createRealExecutor = (options = {}) => {
|
|
416
|
+
const verbose = options.verbose ?? false;
|
|
417
|
+
const logger = createLogger({
|
|
418
|
+
level: verbose ? LogLevel.INFO : LogLevel.WARN,
|
|
419
|
+
prefix: "[tmux]"
|
|
420
|
+
});
|
|
421
|
+
const execute = async (commandOrArgs) => {
|
|
422
|
+
const args = parseCommand$1(commandOrArgs);
|
|
423
|
+
const commandString = toCommandString$1(args);
|
|
424
|
+
logger.info(`Executing: ${commandString}`);
|
|
425
|
+
try {
|
|
426
|
+
return (await execa("tmux", args)).stdout;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
const execaError = error;
|
|
429
|
+
throw createTmuxError("Failed to execute tmux command", ErrorCodes.TMUX_COMMAND_FAILED, {
|
|
430
|
+
command: commandString,
|
|
431
|
+
exitCode: execaError.exitCode,
|
|
432
|
+
stderr: execaError.stderr
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
return {
|
|
437
|
+
execute,
|
|
438
|
+
async executeMany(commandsList) {
|
|
439
|
+
for (const args of commandsList) await execute(args);
|
|
440
|
+
},
|
|
441
|
+
isDryRun() {
|
|
442
|
+
return false;
|
|
443
|
+
},
|
|
444
|
+
logCommand(command) {
|
|
445
|
+
logger.info(`Executing: ${command}`);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/executor/dry-run-executor.ts
|
|
452
|
+
const parseCommand = (commandOrArgs) => {
|
|
453
|
+
return typeof commandOrArgs === "string" ? commandOrArgs.split(" ").filter((segment) => segment.length > 0).slice(1) : commandOrArgs;
|
|
454
|
+
};
|
|
455
|
+
const toCommandString = (args) => {
|
|
456
|
+
return ["tmux", ...args].join(" ");
|
|
457
|
+
};
|
|
458
|
+
const createDryRunExecutor = (options = {}) => {
|
|
459
|
+
const verbose = options.verbose ?? false;
|
|
460
|
+
const logger = createLogger({
|
|
461
|
+
level: verbose ? LogLevel.INFO : LogLevel.WARN,
|
|
462
|
+
prefix: "[tmux] [DRY RUN]"
|
|
463
|
+
});
|
|
464
|
+
const execute = async (commandOrArgs) => {
|
|
465
|
+
const args = parseCommand(commandOrArgs);
|
|
466
|
+
const commandString = toCommandString(args);
|
|
467
|
+
logger.info(`Would execute: ${commandString}`);
|
|
468
|
+
return "";
|
|
469
|
+
};
|
|
470
|
+
return {
|
|
471
|
+
execute,
|
|
472
|
+
async executeMany(commandsList) {
|
|
473
|
+
for (const args of commandsList) await execute(args);
|
|
474
|
+
},
|
|
475
|
+
isDryRun() {
|
|
476
|
+
return true;
|
|
477
|
+
},
|
|
478
|
+
logCommand(command) {
|
|
479
|
+
logger.info(`Would execute: ${command}`);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/core/errors.ts
|
|
486
|
+
const createFunctionalError = (kind, error) => ({
|
|
487
|
+
kind,
|
|
488
|
+
code: error.code,
|
|
489
|
+
message: error.message,
|
|
490
|
+
source: error.source,
|
|
491
|
+
path: error.path,
|
|
492
|
+
details: error.details
|
|
493
|
+
});
|
|
494
|
+
const isFunctionalCoreError = (value) => {
|
|
495
|
+
if (typeof value !== "object" || value === null) return false;
|
|
496
|
+
const candidate = value;
|
|
497
|
+
return (candidate.kind === "compile" || candidate.kind === "plan" || candidate.kind === "emit" || candidate.kind === "execution") && typeof candidate.code === "string" && typeof candidate.message === "string";
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/executor/plan-runner.ts
|
|
502
|
+
const DOUBLE_QUOTE = "\"";
|
|
503
|
+
const ESCAPED_DOUBLE_QUOTE = "\\\"";
|
|
504
|
+
const executePlan = async ({ emission, executor, windowName }) => {
|
|
505
|
+
const initialVirtualPaneId = emission.summary.initialPaneId;
|
|
506
|
+
if (typeof initialVirtualPaneId !== "string" || initialVirtualPaneId.length === 0) raiseExecutionError("INVALID_PLAN", {
|
|
507
|
+
message: "Plan emission is missing initial pane metadata",
|
|
508
|
+
path: "plan.initialPaneId"
|
|
509
|
+
});
|
|
510
|
+
const paneMap = /* @__PURE__ */ new Map();
|
|
511
|
+
const newWindowCommand = [
|
|
512
|
+
"new-window",
|
|
513
|
+
"-P",
|
|
514
|
+
"-F",
|
|
515
|
+
"#{pane_id}"
|
|
516
|
+
];
|
|
517
|
+
if (typeof windowName === "string" && windowName.trim().length > 0) newWindowCommand.push("-n", windowName.trim());
|
|
518
|
+
const initialPaneId = normalizePaneId(await executeCommand(executor, newWindowCommand, {
|
|
519
|
+
code: ErrorCodes.TMUX_COMMAND_FAILED,
|
|
520
|
+
message: "Failed to create tmux window",
|
|
521
|
+
path: initialVirtualPaneId
|
|
522
|
+
}));
|
|
523
|
+
registerPane(paneMap, initialVirtualPaneId, initialPaneId);
|
|
524
|
+
let executedSteps = 0;
|
|
525
|
+
for (const step of emission.steps) {
|
|
526
|
+
if (step.kind === "split") await executeSplitStep({
|
|
527
|
+
step,
|
|
528
|
+
executor,
|
|
529
|
+
paneMap
|
|
530
|
+
});
|
|
531
|
+
else if (step.kind === "focus") await executeFocusStep({
|
|
532
|
+
step,
|
|
533
|
+
executor,
|
|
534
|
+
paneMap
|
|
535
|
+
});
|
|
536
|
+
executedSteps += 1;
|
|
537
|
+
}
|
|
538
|
+
await executeTerminalCommands({
|
|
539
|
+
terminals: emission.terminals,
|
|
540
|
+
executor,
|
|
541
|
+
paneMap
|
|
542
|
+
});
|
|
543
|
+
const finalRealFocus = resolvePaneId(paneMap, emission.summary.focusPaneId);
|
|
544
|
+
if (typeof finalRealFocus === "string" && finalRealFocus.length > 0) await executeCommand(executor, [
|
|
545
|
+
"select-pane",
|
|
546
|
+
"-t",
|
|
547
|
+
finalRealFocus
|
|
548
|
+
], {
|
|
549
|
+
code: ErrorCodes.TMUX_COMMAND_FAILED,
|
|
550
|
+
message: "Failed to restore focus",
|
|
551
|
+
path: emission.summary.focusPaneId
|
|
552
|
+
});
|
|
553
|
+
return { executedSteps };
|
|
554
|
+
};
|
|
555
|
+
const executeSplitStep = async ({ step, executor, paneMap }) => {
|
|
556
|
+
const targetVirtualId = ensureNonEmpty(step.targetPaneId, () => raiseExecutionError("MISSING_TARGET", {
|
|
557
|
+
message: "Split step missing target pane metadata",
|
|
558
|
+
path: step.id
|
|
559
|
+
}));
|
|
560
|
+
const targetRealId = ensureNonEmpty(resolvePaneId(paneMap, targetVirtualId), () => raiseExecutionError("UNKNOWN_PANE", {
|
|
561
|
+
message: `Unknown target pane: ${targetVirtualId}`,
|
|
562
|
+
path: step.id
|
|
563
|
+
}));
|
|
564
|
+
const panesBefore = await listPaneIds(executor, step);
|
|
565
|
+
const splitCommand = replaceTarget(step.command, targetRealId);
|
|
566
|
+
await executeCommand(executor, splitCommand, {
|
|
567
|
+
code: ErrorCodes.TMUX_COMMAND_FAILED,
|
|
568
|
+
message: `Failed to execute split step ${step.id}`,
|
|
569
|
+
path: step.id,
|
|
570
|
+
details: { command: splitCommand }
|
|
571
|
+
});
|
|
572
|
+
const panesAfter = await listPaneIds(executor, step);
|
|
573
|
+
const newPaneId = ensureNonEmpty(findNewPaneId(panesBefore, panesAfter), () => raiseExecutionError("UNKNOWN_PANE", {
|
|
574
|
+
message: "Unable to determine newly created pane",
|
|
575
|
+
path: step.id
|
|
576
|
+
}));
|
|
577
|
+
const createdVirtualId = step.createdPaneId;
|
|
578
|
+
if (typeof createdVirtualId === "string" && createdVirtualId.length > 0) registerPane(paneMap, createdVirtualId, newPaneId);
|
|
579
|
+
};
|
|
580
|
+
const executeFocusStep = async ({ step, executor, paneMap }) => {
|
|
581
|
+
const targetVirtualId = ensureNonEmpty(step.targetPaneId, () => raiseExecutionError("MISSING_TARGET", {
|
|
582
|
+
message: "Focus step missing target pane metadata",
|
|
583
|
+
path: step.id
|
|
584
|
+
}));
|
|
585
|
+
const targetRealId = ensureNonEmpty(resolvePaneId(paneMap, targetVirtualId), () => raiseExecutionError("UNKNOWN_PANE", {
|
|
586
|
+
message: `Unknown focus pane: ${targetVirtualId}`,
|
|
587
|
+
path: step.id
|
|
588
|
+
}));
|
|
589
|
+
const command = replaceTarget(step.command, targetRealId);
|
|
590
|
+
await executeCommand(executor, command, {
|
|
591
|
+
code: ErrorCodes.TMUX_COMMAND_FAILED,
|
|
592
|
+
message: `Failed to execute focus step ${step.id}`,
|
|
593
|
+
path: step.id,
|
|
594
|
+
details: { command }
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
const executeTerminalCommands = async ({ terminals, executor, paneMap }) => {
|
|
598
|
+
for (const terminal of terminals) {
|
|
599
|
+
const realPaneId = ensureNonEmpty(resolvePaneId(paneMap, terminal.virtualPaneId), () => raiseExecutionError("UNKNOWN_PANE", {
|
|
600
|
+
message: `Unknown terminal pane: ${terminal.virtualPaneId}`,
|
|
601
|
+
path: terminal.virtualPaneId
|
|
602
|
+
}));
|
|
603
|
+
if (typeof terminal.cwd === "string" && terminal.cwd.length > 0) {
|
|
604
|
+
const escapedCwd = terminal.cwd.split(DOUBLE_QUOTE).join(ESCAPED_DOUBLE_QUOTE);
|
|
605
|
+
await executeCommand(executor, [
|
|
606
|
+
"send-keys",
|
|
607
|
+
"-t",
|
|
608
|
+
realPaneId,
|
|
609
|
+
`cd "${escapedCwd}"`,
|
|
610
|
+
"Enter"
|
|
611
|
+
], {
|
|
612
|
+
code: ErrorCodes.TMUX_COMMAND_FAILED,
|
|
613
|
+
message: `Failed to change directory for pane ${terminal.virtualPaneId}`,
|
|
614
|
+
path: terminal.virtualPaneId,
|
|
615
|
+
details: { cwd: terminal.cwd }
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
if (terminal.env !== void 0) for (const [key, value] of Object.entries(terminal.env)) {
|
|
619
|
+
const escaped = String(value).split(DOUBLE_QUOTE).join(ESCAPED_DOUBLE_QUOTE);
|
|
620
|
+
await executeCommand(executor, [
|
|
621
|
+
"send-keys",
|
|
622
|
+
"-t",
|
|
623
|
+
realPaneId,
|
|
624
|
+
`export ${key}="${escaped}"`,
|
|
625
|
+
"Enter"
|
|
626
|
+
], {
|
|
627
|
+
code: ErrorCodes.TMUX_COMMAND_FAILED,
|
|
628
|
+
message: `Failed to set environment variable ${key}`,
|
|
629
|
+
path: terminal.virtualPaneId
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
if (typeof terminal.command === "string" && terminal.command.length > 0) await executeCommand(executor, [
|
|
633
|
+
"send-keys",
|
|
634
|
+
"-t",
|
|
635
|
+
realPaneId,
|
|
636
|
+
terminal.command,
|
|
637
|
+
"Enter"
|
|
638
|
+
], {
|
|
639
|
+
code: ErrorCodes.TMUX_COMMAND_FAILED,
|
|
640
|
+
message: `Failed to execute command for pane ${terminal.virtualPaneId}`,
|
|
641
|
+
path: terminal.virtualPaneId,
|
|
642
|
+
details: { command: terminal.command }
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
const executeCommand = async (executor, command, context) => {
|
|
647
|
+
try {
|
|
648
|
+
return await executor.execute([...command]);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (error instanceof Error && "code" in error && "message" in error) {
|
|
651
|
+
const candidate = error;
|
|
652
|
+
throw createFunctionalError("execution", {
|
|
653
|
+
code: typeof candidate.code === "string" ? candidate.code : context.code,
|
|
654
|
+
message: candidate.message ?? context.message,
|
|
655
|
+
path: context.path,
|
|
656
|
+
details: candidate.details ?? context.details
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
throw createFunctionalError("execution", {
|
|
660
|
+
code: context.code,
|
|
661
|
+
message: context.message,
|
|
662
|
+
path: context.path,
|
|
663
|
+
details: context.details
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
const listPaneIds = async (executor, step) => {
|
|
668
|
+
return (await executeCommand(executor, [
|
|
669
|
+
"list-panes",
|
|
670
|
+
"-F",
|
|
671
|
+
"#{pane_id}"
|
|
672
|
+
], {
|
|
673
|
+
code: ErrorCodes.TMUX_COMMAND_FAILED,
|
|
674
|
+
message: "Failed to list tmux panes",
|
|
675
|
+
path: step.id
|
|
676
|
+
})).split("\n").map((pane) => pane.trim()).filter((pane) => pane.length > 0);
|
|
677
|
+
};
|
|
678
|
+
const findNewPaneId = (before, after) => {
|
|
679
|
+
const beforeSet = new Set(before);
|
|
680
|
+
return after.find((id) => !beforeSet.has(id));
|
|
681
|
+
};
|
|
682
|
+
const replaceTarget = (command, realTarget) => {
|
|
683
|
+
const next = [...command];
|
|
684
|
+
const targetIndex = next.findIndex((value, index) => value === "-t" && index + 1 < next.length);
|
|
685
|
+
if (targetIndex >= 0) {
|
|
686
|
+
next[targetIndex + 1] = realTarget;
|
|
687
|
+
return next;
|
|
688
|
+
}
|
|
689
|
+
if (next.length > 0) next[next.length - 1] = realTarget;
|
|
690
|
+
return next;
|
|
691
|
+
};
|
|
692
|
+
const normalizePaneId = (raw) => {
|
|
693
|
+
const trimmed = raw.trim();
|
|
694
|
+
return trimmed.length === 0 ? "%0" : trimmed;
|
|
695
|
+
};
|
|
696
|
+
const registerPane = (paneMap, virtualId, realId) => {
|
|
697
|
+
paneMap.set(virtualId, realId);
|
|
698
|
+
};
|
|
699
|
+
const resolvePaneId = (paneMap, virtualId) => {
|
|
700
|
+
const direct = paneMap.get(virtualId);
|
|
701
|
+
if (typeof direct === "string" && direct.length > 0) return direct;
|
|
702
|
+
let ancestor = virtualId;
|
|
703
|
+
while (ancestor.includes(".")) {
|
|
704
|
+
ancestor = ancestor.slice(0, ancestor.lastIndexOf("."));
|
|
705
|
+
const candidate = paneMap.get(ancestor);
|
|
706
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
707
|
+
paneMap.set(virtualId, candidate);
|
|
708
|
+
return candidate;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
for (const [key, value] of paneMap.entries()) if (key.startsWith(`${virtualId}.`)) {
|
|
712
|
+
if (typeof value === "string" && value.length > 0) {
|
|
713
|
+
paneMap.set(virtualId, value);
|
|
714
|
+
return value;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
const ensureNonEmpty = (value, buildError) => {
|
|
719
|
+
if (value === void 0 || value.length === 0) return buildError();
|
|
720
|
+
return value;
|
|
721
|
+
};
|
|
722
|
+
const raiseExecutionError = (code, error) => {
|
|
723
|
+
throw createFunctionalError("execution", {
|
|
724
|
+
code,
|
|
725
|
+
message: error.message,
|
|
726
|
+
path: error.path,
|
|
727
|
+
details: error.details
|
|
728
|
+
});
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
//#endregion
|
|
732
|
+
//#region src/core/diagnostics.ts
|
|
733
|
+
const severityRank = {
|
|
734
|
+
high: 3,
|
|
735
|
+
medium: 2,
|
|
736
|
+
low: 1
|
|
737
|
+
};
|
|
738
|
+
const createAccumulator = () => {
|
|
739
|
+
const findings = [];
|
|
740
|
+
const nextSteps = /* @__PURE__ */ new Set();
|
|
741
|
+
const backlog = /* @__PURE__ */ new Map();
|
|
742
|
+
return {
|
|
743
|
+
findings,
|
|
744
|
+
nextSteps,
|
|
745
|
+
backlog,
|
|
746
|
+
add: ({ path: path$1, severity, description, nextStep }) => {
|
|
747
|
+
findings.push({
|
|
748
|
+
path: path$1,
|
|
749
|
+
severity,
|
|
750
|
+
description
|
|
751
|
+
});
|
|
752
|
+
if (typeof nextStep === "string" && nextStep.length > 0) nextSteps.add(nextStep);
|
|
753
|
+
const existing = backlog.get(path$1);
|
|
754
|
+
const existingActions = new Set(existing?.actions ?? []);
|
|
755
|
+
existingActions.add(description);
|
|
756
|
+
if (typeof nextStep === "string" && nextStep.length > 0) existingActions.add(nextStep);
|
|
757
|
+
const mergedSeverity = existing !== void 0 ? maxSeverity(existing.severity, severity) : severity;
|
|
758
|
+
const summary = existing !== void 0 && severityRank[existing.severity] >= severityRank[severity] ? existing.summary : description;
|
|
759
|
+
backlog.set(path$1, {
|
|
760
|
+
id: path$1,
|
|
761
|
+
severity: mergedSeverity,
|
|
762
|
+
summary,
|
|
763
|
+
actions: Array.from(existingActions)
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
};
|
|
768
|
+
const runDiagnostics = (input) => {
|
|
769
|
+
const accumulator = createAccumulator();
|
|
770
|
+
const knownIssues = input.knownIssues ?? [];
|
|
771
|
+
let parsedPreset;
|
|
772
|
+
try {
|
|
773
|
+
parsedPreset = parse(input.presetDocument);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
accumulator.add({
|
|
776
|
+
path: "presetDocument",
|
|
777
|
+
severity: "high",
|
|
778
|
+
description: `プリセットYAMLの解析に失敗しました: ${error.message}`,
|
|
779
|
+
nextStep: "プリセットYAMLを構文チェックし、Functional Coreリライト前に整合性を確保する"
|
|
780
|
+
});
|
|
781
|
+
return {
|
|
782
|
+
findings: sortBySeverity(accumulator.findings),
|
|
783
|
+
nextSteps: Array.from(accumulator.nextSteps),
|
|
784
|
+
backlog: sortBacklog(accumulator.backlog)
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
if (parsedPreset === null || typeof parsedPreset !== "object") accumulator.add({
|
|
788
|
+
path: "preset",
|
|
789
|
+
severity: "high",
|
|
790
|
+
description: "プリセット定義がオブジェクト形式ではありません",
|
|
791
|
+
nextStep: "プリセットYAMLをオブジェクト構造に整形し整合性を確保する"
|
|
792
|
+
});
|
|
793
|
+
else {
|
|
794
|
+
const presetObject = parsedPreset;
|
|
795
|
+
checkFocusDuplications(presetObject, accumulator);
|
|
796
|
+
collectLowPrioritySignals(presetObject, accumulator);
|
|
797
|
+
}
|
|
798
|
+
knownIssues.forEach((issue, index) => {
|
|
799
|
+
const trimmed = issue.trim();
|
|
800
|
+
if (trimmed.length === 0) return;
|
|
801
|
+
accumulator.add({
|
|
802
|
+
path: `codebase.knownIssues[${index}]`,
|
|
803
|
+
severity: "medium",
|
|
804
|
+
description: trimmed,
|
|
805
|
+
nextStep: `tmux依存や副作用をFunctional Core境界で切り離す: ${trimmed}`
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
return {
|
|
809
|
+
findings: sortBySeverity(accumulator.findings),
|
|
810
|
+
nextSteps: Array.from(accumulator.nextSteps),
|
|
811
|
+
backlog: sortBacklog(accumulator.backlog)
|
|
812
|
+
};
|
|
813
|
+
};
|
|
814
|
+
const checkFocusDuplications = (preset, accumulator) => {
|
|
815
|
+
const layout = preset.layout;
|
|
816
|
+
if (layout === void 0 || layout === null) return;
|
|
817
|
+
if (countFocusFlags(layout) > 1) accumulator.add({
|
|
818
|
+
path: "preset.layout",
|
|
819
|
+
severity: "high",
|
|
820
|
+
description: "複数のペインでfocus: trueが指定されています",
|
|
821
|
+
nextStep: "focusは単一ペインに限定しPlan生成時に一貫するようFunctional Coreで制御する"
|
|
822
|
+
});
|
|
823
|
+
};
|
|
824
|
+
const collectLowPrioritySignals = (preset, accumulator) => {
|
|
825
|
+
const layout = preset.layout;
|
|
826
|
+
if (layout === void 0 || layout === null) {
|
|
827
|
+
accumulator.add({
|
|
828
|
+
path: "preset.layout",
|
|
829
|
+
severity: "low",
|
|
830
|
+
description: "layout定義が存在しません(単一ペイン運用が前提)",
|
|
831
|
+
nextStep: "単一ペイン前提でもPlan出力に影響しないことをFunctional Coreで確認する"
|
|
832
|
+
});
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (!Array.isArray(layout.panes)) accumulator.add({
|
|
836
|
+
path: "preset.layout.panes",
|
|
837
|
+
severity: "low",
|
|
838
|
+
description: "panes配列が存在しないか配列ではありません",
|
|
839
|
+
nextStep: "レイアウト定義の構造を正規化し、Plan生成の入力契約を明示する"
|
|
840
|
+
});
|
|
841
|
+
};
|
|
842
|
+
const countFocusFlags = (node) => {
|
|
843
|
+
if (Array.isArray(node)) return node.reduce((sum, child) => sum + countFocusFlags(child), 0);
|
|
844
|
+
if (node === null || typeof node !== "object") return 0;
|
|
845
|
+
const record = node;
|
|
846
|
+
const selfFocus = record.focus === true ? 1 : 0;
|
|
847
|
+
const childFocus = Array.isArray(record.panes) ? record.panes.reduce((sum, child) => sum + countFocusFlags(child), 0) : 0;
|
|
848
|
+
return selfFocus + childFocus;
|
|
849
|
+
};
|
|
850
|
+
const sortBySeverity = (findings) => {
|
|
851
|
+
return [...findings].sort((a, b) => severityRank[b.severity] - severityRank[a.severity]);
|
|
852
|
+
};
|
|
853
|
+
const sortBacklog = (backlog) => {
|
|
854
|
+
return [...backlog.values()].sort((a, b) => severityRank[b.severity] - severityRank[a.severity]);
|
|
855
|
+
};
|
|
856
|
+
const maxSeverity = (left, right) => {
|
|
857
|
+
return severityRank[left] >= severityRank[right] ? left : right;
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
//#endregion
|
|
861
|
+
//#region src/core/compile.ts
|
|
862
|
+
const compilePreset = ({ document, source }) => {
|
|
863
|
+
let parsed;
|
|
864
|
+
try {
|
|
865
|
+
parsed = parse(document);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
throw compileError("PRESET_PARSE_ERROR", {
|
|
868
|
+
source,
|
|
869
|
+
message: `YAMLの解析に失敗しました: ${error.message}`,
|
|
870
|
+
details: { reason: error instanceof Error ? error.message : String(error) }
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
if (!isRecord(parsed)) throw compileError("PRESET_INVALID_DOCUMENT", {
|
|
874
|
+
source,
|
|
875
|
+
message: "プリセット定義がオブジェクトではありません",
|
|
876
|
+
path: "preset"
|
|
877
|
+
});
|
|
878
|
+
const name = typeof parsed.name === "string" && parsed.name.trim().length > 0 ? parsed.name : "Unnamed preset";
|
|
879
|
+
const layout = parseLayoutNode(parsed.layout, {
|
|
880
|
+
source,
|
|
881
|
+
path: "preset.layout"
|
|
882
|
+
});
|
|
883
|
+
return { preset: {
|
|
884
|
+
name,
|
|
885
|
+
version: "legacy",
|
|
886
|
+
command: typeof parsed.command === "string" ? parsed.command : void 0,
|
|
887
|
+
layout: layout ?? void 0,
|
|
888
|
+
metadata: { source }
|
|
889
|
+
} };
|
|
890
|
+
};
|
|
891
|
+
const parseLayoutNode = (node, context) => {
|
|
892
|
+
if (node === void 0 || node === null) return null;
|
|
893
|
+
if (!isRecord(node)) throw compileError("LAYOUT_INVALID_NODE", {
|
|
894
|
+
source: context.source,
|
|
895
|
+
message: "レイアウトノードの形式が不正です",
|
|
896
|
+
path: context.path,
|
|
897
|
+
details: { node }
|
|
898
|
+
});
|
|
899
|
+
if (typeof node.type === "string" && Array.isArray(node.panes)) return parseSplitPane(node, context);
|
|
900
|
+
if (typeof node.name === "string") return parseTerminalPane(node);
|
|
901
|
+
throw compileError("LAYOUT_INVALID_NODE", {
|
|
902
|
+
source: context.source,
|
|
903
|
+
message: "レイアウトノードの形式が不正です",
|
|
904
|
+
path: context.path,
|
|
905
|
+
details: { node }
|
|
906
|
+
});
|
|
907
|
+
};
|
|
908
|
+
const parseSplitPane = (node, context) => {
|
|
909
|
+
const orientation = node.type;
|
|
910
|
+
if (orientation !== "horizontal" && orientation !== "vertical") throw compileError("LAYOUT_INVALID_ORIENTATION", {
|
|
911
|
+
source: context.source,
|
|
912
|
+
message: "layout.type は horizontal か vertical である必要があります",
|
|
913
|
+
path: `${context.path}.type`,
|
|
914
|
+
details: { type: orientation }
|
|
915
|
+
});
|
|
916
|
+
if (!Array.isArray(node.panes) || node.panes.length === 0) throw compileError("LAYOUT_PANES_MISSING", {
|
|
917
|
+
source: context.source,
|
|
918
|
+
message: "panes 配列が存在しません",
|
|
919
|
+
path: `${context.path}.panes`
|
|
920
|
+
});
|
|
921
|
+
if (!Array.isArray(node.ratio) || node.ratio.length === 0) throw compileError("LAYOUT_RATIO_MISSING", {
|
|
922
|
+
source: context.source,
|
|
923
|
+
message: "ratio 配列が存在しません",
|
|
924
|
+
path: `${context.path}.ratio`
|
|
925
|
+
});
|
|
926
|
+
if (node.ratio.length !== node.panes.length) throw compileError("LAYOUT_RATIO_MISMATCH", {
|
|
927
|
+
source: context.source,
|
|
928
|
+
message: "ratio 配列と panes 配列の長さが一致しません",
|
|
929
|
+
path: context.path,
|
|
930
|
+
details: {
|
|
931
|
+
ratioLength: node.ratio.length,
|
|
932
|
+
panesLength: node.panes.length
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
const ratio = node.ratio.map((value, index) => {
|
|
936
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw compileError("RATIO_INVALID_VALUE", {
|
|
937
|
+
source: context.source,
|
|
938
|
+
message: "ratio の値が正の数値ではありません",
|
|
939
|
+
path: `${context.path}.ratio[${index}]`,
|
|
940
|
+
details: { value }
|
|
941
|
+
});
|
|
942
|
+
return value;
|
|
943
|
+
});
|
|
944
|
+
const panes = node.panes.map((child, index) => parseLayoutNode(child, {
|
|
945
|
+
source: context.source,
|
|
946
|
+
path: `${context.path}.panes[${index}]`
|
|
947
|
+
}));
|
|
948
|
+
return {
|
|
949
|
+
kind: "split",
|
|
950
|
+
orientation,
|
|
951
|
+
ratio,
|
|
952
|
+
panes: panes.filter((pane) => pane !== null)
|
|
953
|
+
};
|
|
954
|
+
};
|
|
955
|
+
const parseTerminalPane = (node) => {
|
|
956
|
+
const name = typeof node.name === "string" ? node.name : "";
|
|
957
|
+
const command = typeof node.command === "string" ? node.command : void 0;
|
|
958
|
+
const cwd = typeof node.cwd === "string" ? node.cwd : void 0;
|
|
959
|
+
const focus = node.focus === true ? true : void 0;
|
|
960
|
+
const env = normalizeEnv(node.env);
|
|
961
|
+
const options = collectOptions(node, new Set([
|
|
962
|
+
"name",
|
|
963
|
+
"command",
|
|
964
|
+
"cwd",
|
|
965
|
+
"env",
|
|
966
|
+
"focus",
|
|
967
|
+
"options",
|
|
968
|
+
"title",
|
|
969
|
+
"delay"
|
|
970
|
+
]));
|
|
971
|
+
return {
|
|
972
|
+
kind: "terminal",
|
|
973
|
+
name,
|
|
974
|
+
command,
|
|
975
|
+
cwd,
|
|
976
|
+
env,
|
|
977
|
+
focus,
|
|
978
|
+
options
|
|
979
|
+
};
|
|
980
|
+
};
|
|
981
|
+
const normalizeEnv = (env) => {
|
|
982
|
+
if (!isRecord(env)) return;
|
|
983
|
+
const entries = Object.entries(env).reduce((accumulator, [key, value]) => {
|
|
984
|
+
if (typeof value === "string") accumulator[key] = value;
|
|
985
|
+
return accumulator;
|
|
986
|
+
}, {});
|
|
987
|
+
return Object.keys(entries).length > 0 ? entries : void 0;
|
|
988
|
+
};
|
|
989
|
+
const collectOptions = (node, excludedKeys) => {
|
|
990
|
+
const optionsEntries = Object.entries(node).filter(([key]) => !excludedKeys.has(key));
|
|
991
|
+
if (optionsEntries.length === 0) return;
|
|
992
|
+
return optionsEntries.reduce((accumulator, [key, value]) => {
|
|
993
|
+
accumulator[key] = value;
|
|
994
|
+
return accumulator;
|
|
995
|
+
}, {});
|
|
996
|
+
};
|
|
997
|
+
const isRecord = (value) => {
|
|
998
|
+
return typeof value === "object" && value !== null;
|
|
999
|
+
};
|
|
1000
|
+
const compileError = (code, error) => {
|
|
1001
|
+
return createFunctionalError("compile", {
|
|
1002
|
+
code,
|
|
1003
|
+
message: error.message,
|
|
1004
|
+
source: error.source,
|
|
1005
|
+
path: error.path,
|
|
1006
|
+
details: error.details
|
|
1007
|
+
});
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/core/planner.ts
|
|
1012
|
+
const createLayoutPlan = ({ preset }) => {
|
|
1013
|
+
if (!preset.layout) {
|
|
1014
|
+
const terminal = createTerminalNode({
|
|
1015
|
+
id: "root",
|
|
1016
|
+
terminal: {
|
|
1017
|
+
kind: "terminal",
|
|
1018
|
+
name: preset.name,
|
|
1019
|
+
command: preset.command
|
|
1020
|
+
},
|
|
1021
|
+
focusOverride: true
|
|
1022
|
+
});
|
|
1023
|
+
return { plan: {
|
|
1024
|
+
root: terminal,
|
|
1025
|
+
focusPaneId: terminal.id
|
|
1026
|
+
} };
|
|
1027
|
+
}
|
|
1028
|
+
const { node, focusPaneIds, terminalPaneIds } = buildLayoutNode(preset.layout, {
|
|
1029
|
+
parentId: "root",
|
|
1030
|
+
path: "preset.layout",
|
|
1031
|
+
source: preset.metadata.source
|
|
1032
|
+
});
|
|
1033
|
+
if (focusPaneIds.length > 1) throw planError("FOCUS_CONFLICT", {
|
|
1034
|
+
message: "複数のペインでfocusが指定されています",
|
|
1035
|
+
path: "preset.layout",
|
|
1036
|
+
source: preset.metadata.source,
|
|
1037
|
+
details: { focusPaneIds }
|
|
1038
|
+
});
|
|
1039
|
+
if (terminalPaneIds.length === 0) throw planError("NO_TERMINAL_PANES", {
|
|
1040
|
+
message: "ターミナルペインが存在しません",
|
|
1041
|
+
path: "preset.layout",
|
|
1042
|
+
source: preset.metadata.source
|
|
1043
|
+
});
|
|
1044
|
+
const focusPaneId = focusPaneIds[0] ?? terminalPaneIds[0];
|
|
1045
|
+
return { plan: {
|
|
1046
|
+
root: ensureFocus(node, focusPaneId),
|
|
1047
|
+
focusPaneId
|
|
1048
|
+
} };
|
|
1049
|
+
};
|
|
1050
|
+
const buildLayoutNode = (node, context) => {
|
|
1051
|
+
if (node.kind === "split") return buildSplitNode(node, context);
|
|
1052
|
+
return {
|
|
1053
|
+
node: createTerminalNode({
|
|
1054
|
+
id: context.parentId,
|
|
1055
|
+
terminal: node
|
|
1056
|
+
}),
|
|
1057
|
+
focusPaneIds: node.focus === true ? [context.parentId] : [],
|
|
1058
|
+
terminalPaneIds: [context.parentId]
|
|
1059
|
+
};
|
|
1060
|
+
};
|
|
1061
|
+
const buildSplitNode = (node, context) => {
|
|
1062
|
+
const ratio = normalizeRatio(node.ratio, context);
|
|
1063
|
+
const panes = [];
|
|
1064
|
+
const focusPaneIds = [];
|
|
1065
|
+
const terminalPaneIds = [];
|
|
1066
|
+
for (let index = 0; index < node.panes.length; index += 1) {
|
|
1067
|
+
const childContext = {
|
|
1068
|
+
parentId: `${context.parentId}.${index}`,
|
|
1069
|
+
path: `${context.path}.panes[${index}]`,
|
|
1070
|
+
source: context.source
|
|
1071
|
+
};
|
|
1072
|
+
const childResult = buildLayoutNode(node.panes[index], childContext);
|
|
1073
|
+
panes.push(childResult.node);
|
|
1074
|
+
focusPaneIds.push(...childResult.focusPaneIds);
|
|
1075
|
+
terminalPaneIds.push(...childResult.terminalPaneIds);
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
node: {
|
|
1079
|
+
kind: "split",
|
|
1080
|
+
id: context.parentId,
|
|
1081
|
+
orientation: node.orientation,
|
|
1082
|
+
ratio,
|
|
1083
|
+
panes
|
|
1084
|
+
},
|
|
1085
|
+
focusPaneIds,
|
|
1086
|
+
terminalPaneIds
|
|
1087
|
+
};
|
|
1088
|
+
};
|
|
1089
|
+
const createTerminalNode = ({ id, terminal, focusOverride }) => {
|
|
1090
|
+
return {
|
|
1091
|
+
kind: "terminal",
|
|
1092
|
+
id,
|
|
1093
|
+
name: terminal.name,
|
|
1094
|
+
command: terminal.command,
|
|
1095
|
+
cwd: terminal.cwd,
|
|
1096
|
+
env: terminal.env,
|
|
1097
|
+
options: terminal.options,
|
|
1098
|
+
focus: focusOverride === true ? true : terminal.focus === true
|
|
1099
|
+
};
|
|
1100
|
+
};
|
|
1101
|
+
const ensureFocus = (node, focusPaneId) => {
|
|
1102
|
+
if (node.kind === "terminal") return {
|
|
1103
|
+
...node,
|
|
1104
|
+
focus: node.id === focusPaneId
|
|
1105
|
+
};
|
|
1106
|
+
return {
|
|
1107
|
+
...node,
|
|
1108
|
+
panes: node.panes.map((pane) => ensureFocus(pane, focusPaneId))
|
|
1109
|
+
};
|
|
1110
|
+
};
|
|
1111
|
+
const normalizeRatio = (ratio, context) => {
|
|
1112
|
+
const total = ratio.reduce((sum, value, index) => {
|
|
1113
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw planError("RATIO_INVALID_VALUE", {
|
|
1114
|
+
message: "ratio の値が非負の数値ではありません",
|
|
1115
|
+
path: `${context.path}.ratio[${index}]`,
|
|
1116
|
+
source: context.source,
|
|
1117
|
+
details: { value }
|
|
1118
|
+
});
|
|
1119
|
+
return sum + value;
|
|
1120
|
+
}, 0);
|
|
1121
|
+
if (total === 0) return ratio.map(() => 1 / ratio.length);
|
|
1122
|
+
return ratio.map((value) => value / total);
|
|
1123
|
+
};
|
|
1124
|
+
const planError = (code, error) => {
|
|
1125
|
+
return createFunctionalError("plan", {
|
|
1126
|
+
code,
|
|
1127
|
+
message: error.message,
|
|
1128
|
+
source: error.source,
|
|
1129
|
+
path: error.path,
|
|
1130
|
+
details: error.details
|
|
1131
|
+
});
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/core/emitter.ts
|
|
1136
|
+
const emitPlan = ({ plan }) => {
|
|
1137
|
+
const steps = [];
|
|
1138
|
+
collectSplitSteps(plan.root, steps);
|
|
1139
|
+
steps.push({
|
|
1140
|
+
id: `${plan.focusPaneId}:focus`,
|
|
1141
|
+
kind: "focus",
|
|
1142
|
+
command: [
|
|
1143
|
+
"select-pane",
|
|
1144
|
+
"-t",
|
|
1145
|
+
plan.focusPaneId
|
|
1146
|
+
],
|
|
1147
|
+
summary: `select pane ${plan.focusPaneId}`,
|
|
1148
|
+
targetPaneId: plan.focusPaneId
|
|
1149
|
+
});
|
|
1150
|
+
const hash = createPlanHash(plan, steps);
|
|
1151
|
+
const initialPaneId = determineInitialPaneId(plan.root);
|
|
1152
|
+
const terminals = collectTerminals(plan.root);
|
|
1153
|
+
return {
|
|
1154
|
+
steps,
|
|
1155
|
+
summary: {
|
|
1156
|
+
stepsCount: steps.length,
|
|
1157
|
+
focusPaneId: plan.focusPaneId,
|
|
1158
|
+
initialPaneId
|
|
1159
|
+
},
|
|
1160
|
+
terminals,
|
|
1161
|
+
hash
|
|
1162
|
+
};
|
|
1163
|
+
};
|
|
1164
|
+
const collectSplitSteps = (node, steps) => {
|
|
1165
|
+
if (node.kind === "terminal") return;
|
|
1166
|
+
appendSplitSteps(node, steps);
|
|
1167
|
+
node.panes.forEach((pane) => collectSplitSteps(pane, steps));
|
|
1168
|
+
};
|
|
1169
|
+
const appendSplitSteps = (node, steps) => {
|
|
1170
|
+
const directionFlag = node.orientation === "horizontal" ? "-h" : "-v";
|
|
1171
|
+
for (let index = 1; index < node.panes.length; index += 1) {
|
|
1172
|
+
const remainingIncludingTarget = node.ratio.slice(index - 1).reduce((sum, value) => sum + value, 0);
|
|
1173
|
+
const remainingAfterTarget = node.ratio.slice(index).reduce((sum, value) => sum + value, 0);
|
|
1174
|
+
const desiredPercentage = remainingIncludingTarget === 0 ? 0 : remainingAfterTarget / remainingIncludingTarget * 100;
|
|
1175
|
+
const percentage = Math.max(1, Math.round(desiredPercentage));
|
|
1176
|
+
const targetPaneId = node.panes[index - 1]?.id ?? node.id;
|
|
1177
|
+
const createdPaneId = node.panes[index]?.id;
|
|
1178
|
+
steps.push({
|
|
1179
|
+
id: `${node.id}:split:${index}`,
|
|
1180
|
+
kind: "split",
|
|
1181
|
+
command: [
|
|
1182
|
+
"split-window",
|
|
1183
|
+
directionFlag,
|
|
1184
|
+
"-t",
|
|
1185
|
+
targetPaneId,
|
|
1186
|
+
"-p",
|
|
1187
|
+
String(Math.min(percentage, 99))
|
|
1188
|
+
],
|
|
1189
|
+
summary: `split ${targetPaneId} (${directionFlag})`,
|
|
1190
|
+
targetPaneId,
|
|
1191
|
+
createdPaneId
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
const collectTerminals = (node) => {
|
|
1196
|
+
if (node.kind === "terminal") return [{
|
|
1197
|
+
virtualPaneId: node.id,
|
|
1198
|
+
command: node.command,
|
|
1199
|
+
cwd: node.cwd,
|
|
1200
|
+
env: node.env,
|
|
1201
|
+
focus: node.focus,
|
|
1202
|
+
name: node.name
|
|
1203
|
+
}];
|
|
1204
|
+
return node.panes.flatMap((pane) => collectTerminals(pane));
|
|
1205
|
+
};
|
|
1206
|
+
const determineInitialPaneId = (node) => {
|
|
1207
|
+
if (node.kind === "terminal") return node.id;
|
|
1208
|
+
let current = node;
|
|
1209
|
+
while (current.kind === "split") current = current.panes[0];
|
|
1210
|
+
return current.id;
|
|
1211
|
+
};
|
|
1212
|
+
const createPlanHash = (plan, steps) => {
|
|
1213
|
+
const digest = createHash("sha256");
|
|
1214
|
+
const normalized = {
|
|
1215
|
+
focusPaneId: plan.focusPaneId,
|
|
1216
|
+
root: plan.root,
|
|
1217
|
+
steps
|
|
1218
|
+
};
|
|
1219
|
+
digest.update(JSON.stringify(normalized));
|
|
1220
|
+
return digest.digest("hex");
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
//#endregion
|
|
1224
|
+
//#region src/cli.ts
|
|
1225
|
+
const KNOWN_ISSUES = [
|
|
1226
|
+
"LayoutEngineがtmux依存とI/Oを同一クラスで扱っている",
|
|
1227
|
+
"dry-run実行と本番適用でPlan構造が共有されていない",
|
|
1228
|
+
"Loggerが境界層とFunctional Coreの責務を混在させている"
|
|
1229
|
+
];
|
|
1230
|
+
const formatSeverityTag = (severity) => {
|
|
1231
|
+
switch (severity) {
|
|
1232
|
+
case "high": return chalk.red("[HIGH]");
|
|
1233
|
+
case "medium": return chalk.yellow("[MEDIUM]");
|
|
1234
|
+
case "low":
|
|
1235
|
+
default: return chalk.blue("[LOW]");
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
const createCli = (options = {}) => {
|
|
1239
|
+
const presetManager = options.presetManager ?? createPresetManager();
|
|
1240
|
+
const createCommandExecutor = options.createCommandExecutor ?? ((opts) => {
|
|
1241
|
+
if (opts.dryRun) return createDryRunExecutor({ verbose: opts.verbose });
|
|
1242
|
+
return createRealExecutor({ verbose: opts.verbose });
|
|
1243
|
+
});
|
|
1244
|
+
const functionalCore = options.functionalCore ?? {
|
|
1245
|
+
compilePreset,
|
|
1246
|
+
createLayoutPlan,
|
|
1247
|
+
emitPlan
|
|
1248
|
+
};
|
|
1249
|
+
const program = new Command();
|
|
1250
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
1251
|
+
let logger = createLogger();
|
|
1252
|
+
const renderDiagnosticsReport = (report) => {
|
|
1253
|
+
console.log(chalk.bold("\nFunctional Core Diagnostics\n"));
|
|
1254
|
+
if (report.backlog.length > 0) {
|
|
1255
|
+
console.log(chalk.bold("改善バックログ"));
|
|
1256
|
+
report.backlog.forEach((item, index) => {
|
|
1257
|
+
const prefix = `${index + 1}. ${formatSeverityTag(item.severity)}`;
|
|
1258
|
+
console.log(`${prefix} ${item.summary}`);
|
|
1259
|
+
item.actions.forEach((action) => {
|
|
1260
|
+
console.log(` - ${action}`);
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
console.log("");
|
|
1264
|
+
}
|
|
1265
|
+
if (report.findings.length > 0) {
|
|
1266
|
+
console.log(chalk.bold("診断結果"));
|
|
1267
|
+
report.findings.forEach((finding) => {
|
|
1268
|
+
console.log(`${formatSeverityTag(finding.severity)} ${finding.path} :: ${finding.description}`);
|
|
1269
|
+
});
|
|
1270
|
+
console.log("");
|
|
1271
|
+
}
|
|
1272
|
+
if (report.nextSteps.length > 0) {
|
|
1273
|
+
console.log(chalk.bold("次のアクション"));
|
|
1274
|
+
report.nextSteps.forEach((step) => {
|
|
1275
|
+
console.log(` - ${step}`);
|
|
1276
|
+
});
|
|
1277
|
+
console.log("");
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
const renderDryRun = (emission) => {
|
|
1281
|
+
console.log(chalk.bold("\nPlanned tmux steps (dry-run)"));
|
|
1282
|
+
emission.steps.forEach((step, index) => {
|
|
1283
|
+
const commandString = step.command.join(" ");
|
|
1284
|
+
console.log(` ${index + 1}. ${step.summary}: tmux ${commandString}`);
|
|
1285
|
+
});
|
|
1286
|
+
};
|
|
1287
|
+
const buildPresetDocument = (preset, presetName) => {
|
|
1288
|
+
const document = {
|
|
1289
|
+
name: preset.name ?? presetName ?? "vde-layout",
|
|
1290
|
+
command: preset.command,
|
|
1291
|
+
layout: preset.layout
|
|
1292
|
+
};
|
|
1293
|
+
if (typeof preset.command !== "string" || preset.command.length === 0) delete document.command;
|
|
1294
|
+
if (preset.layout === void 0 || preset.layout === null) delete document.layout;
|
|
1295
|
+
return stringify(document);
|
|
1296
|
+
};
|
|
1297
|
+
const buildPresetSource = (presetName) => {
|
|
1298
|
+
return typeof presetName === "string" && presetName.length > 0 ? `preset://${presetName}` : "preset://default";
|
|
1299
|
+
};
|
|
1300
|
+
const handleFunctionalError = (error) => {
|
|
1301
|
+
const header = [`[${error.kind}]`, `[${error.code}]`];
|
|
1302
|
+
if (typeof error.path === "string" && error.path.length > 0) header.push(`[${error.path}]`);
|
|
1303
|
+
const lines = [`${header.join(" ")} ${error.message}`.trim()];
|
|
1304
|
+
if (typeof error.source === "string" && error.source.length > 0) lines.push(`source: ${error.source}`);
|
|
1305
|
+
const commandDetail = error.details?.command;
|
|
1306
|
+
if (Array.isArray(commandDetail)) {
|
|
1307
|
+
const tmuxCommand = commandDetail.filter((segment) => typeof segment === "string");
|
|
1308
|
+
if (tmuxCommand.length > 0) lines.push(`command: tmux ${tmuxCommand.join(" ")}`);
|
|
1309
|
+
} else if (typeof commandDetail === "string" && commandDetail.length > 0) lines.push(`command: ${commandDetail}`);
|
|
1310
|
+
const stderrDetail = error.details?.stderr;
|
|
1311
|
+
if (typeof stderrDetail === "string" && stderrDetail.length > 0) lines.push(`stderr: ${stderrDetail}`);
|
|
1312
|
+
else if (stderrDetail !== void 0) lines.push(`stderr: ${String(stderrDetail)}`);
|
|
1313
|
+
logger.error(lines.join("\n"));
|
|
1314
|
+
process.exit(1);
|
|
1315
|
+
};
|
|
1316
|
+
const handleError = (error) => {
|
|
1317
|
+
if (error instanceof Error) logger.error(error.message, error);
|
|
1318
|
+
else logger.error("An unexpected error occurred");
|
|
1319
|
+
process.exit(1);
|
|
1320
|
+
};
|
|
1321
|
+
const handlePipelineFailure = (error) => {
|
|
1322
|
+
if (isFunctionalCoreError(error)) return handleFunctionalError(error);
|
|
1323
|
+
return handleError(error);
|
|
1324
|
+
};
|
|
1325
|
+
const listPresets = async () => {
|
|
1326
|
+
try {
|
|
1327
|
+
await presetManager.loadConfig();
|
|
1328
|
+
const presets = presetManager.listPresets();
|
|
1329
|
+
if (presets.length === 0) {
|
|
1330
|
+
logger.warn("No presets defined");
|
|
1331
|
+
process.exit(0);
|
|
1332
|
+
}
|
|
1333
|
+
console.log(chalk.bold("Available presets:\n"));
|
|
1334
|
+
const maxKeyLength = Math.max(...presets.map((p) => p.key.length));
|
|
1335
|
+
presets.forEach((preset) => {
|
|
1336
|
+
const paddedKey = preset.key.padEnd(maxKeyLength + 2);
|
|
1337
|
+
const description = preset.description ?? "";
|
|
1338
|
+
console.log(` ${chalk.cyan(paddedKey)} ${description}`);
|
|
1339
|
+
});
|
|
1340
|
+
process.exit(0);
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
return handleError(error);
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
const diagnosePreset = async (presetName) => {
|
|
1346
|
+
try {
|
|
1347
|
+
await presetManager.loadConfig();
|
|
1348
|
+
const preset = typeof presetName === "string" && presetName.length > 0 ? presetManager.getPreset(presetName) : presetManager.getDefaultPreset();
|
|
1349
|
+
const presetDocument = stringify(preset ?? {});
|
|
1350
|
+
const report = runDiagnostics({
|
|
1351
|
+
presetDocument,
|
|
1352
|
+
knownIssues: KNOWN_ISSUES
|
|
1353
|
+
});
|
|
1354
|
+
renderDiagnosticsReport(report);
|
|
1355
|
+
process.exit(0);
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
return handleError(error);
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
const executePreset = async (presetName, options$1) => {
|
|
1361
|
+
try {
|
|
1362
|
+
await presetManager.loadConfig();
|
|
1363
|
+
const preset = typeof presetName === "string" && presetName.length > 0 ? presetManager.getPreset(presetName) : presetManager.getDefaultPreset();
|
|
1364
|
+
const tmuxEnv = process.env.TMUX;
|
|
1365
|
+
if (!(typeof tmuxEnv === "string" && tmuxEnv.length > 0) && options$1.dryRun !== true) throw new Error("Must be run inside a tmux session");
|
|
1366
|
+
const executor = createCommandExecutor({
|
|
1367
|
+
verbose: options$1.verbose,
|
|
1368
|
+
dryRun: options$1.dryRun
|
|
1369
|
+
});
|
|
1370
|
+
if (options$1.dryRun === true) console.log("[DRY RUN] No actual commands will be executed");
|
|
1371
|
+
let compileResult;
|
|
1372
|
+
let planResult;
|
|
1373
|
+
let emission;
|
|
1374
|
+
try {
|
|
1375
|
+
compileResult = functionalCore.compilePreset({
|
|
1376
|
+
document: buildPresetDocument(preset, presetName),
|
|
1377
|
+
source: buildPresetSource(presetName)
|
|
1378
|
+
});
|
|
1379
|
+
planResult = functionalCore.createLayoutPlan({ preset: compileResult.preset });
|
|
1380
|
+
emission = functionalCore.emitPlan({ plan: planResult.plan });
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
return handlePipelineFailure(error);
|
|
1383
|
+
}
|
|
1384
|
+
if (options$1.dryRun === true) renderDryRun(emission);
|
|
1385
|
+
else try {
|
|
1386
|
+
const executionResult = await executePlan({
|
|
1387
|
+
emission,
|
|
1388
|
+
executor,
|
|
1389
|
+
windowName: preset.name ?? presetName ?? "vde-layout"
|
|
1390
|
+
});
|
|
1391
|
+
logger.info(`Executed ${executionResult.executedSteps} tmux steps`);
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
return handlePipelineFailure(error);
|
|
1394
|
+
}
|
|
1395
|
+
logger.success(`✓ Applied preset "${preset.name}"`);
|
|
1396
|
+
process.exit(0);
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
return handleError(error);
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
const setupProgram = () => {
|
|
1402
|
+
program.name("vde-layout").description("VDE (Vibrant Development Environment) Layout Manager - tmux pane layout management tool").version(version, "-V, --version", "Show version").helpOption("-h, --help", "Show help");
|
|
1403
|
+
program.option("-v, --verbose", "Show detailed logs", false);
|
|
1404
|
+
program.option("--dry-run", "Display commands without executing", false);
|
|
1405
|
+
program.option("--config <path>", "Path to configuration file");
|
|
1406
|
+
program.command("list").description("List available presets").action(async () => {
|
|
1407
|
+
await listPresets();
|
|
1408
|
+
});
|
|
1409
|
+
program.command("diagnose").description("Functional Coreリライトに向けた診断レポートを表示する").argument("[preset]", "Preset name (defaults to \"default\" preset when omitted)").action(async (presetName) => {
|
|
1410
|
+
await diagnosePreset(presetName);
|
|
1411
|
+
});
|
|
1412
|
+
program.argument("[preset]", "Preset name (defaults to \"default\" preset when omitted)").action(async (presetName) => {
|
|
1413
|
+
const opts = program.opts();
|
|
1414
|
+
await executePreset(presetName, {
|
|
1415
|
+
verbose: opts.verbose === true,
|
|
1416
|
+
dryRun: opts.dryRun === true
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
};
|
|
1420
|
+
setupProgram();
|
|
1421
|
+
const run = async (args = process.argv.slice(2)) => {
|
|
1422
|
+
const requestedVersion = args.includes("--version") || args.includes("-V");
|
|
1423
|
+
const requestedHelp = args.includes("--help") || args.includes("-h");
|
|
1424
|
+
try {
|
|
1425
|
+
await program.parseAsync(args, { from: "user" });
|
|
1426
|
+
const opts = program.opts();
|
|
1427
|
+
if (requestedVersion || requestedHelp) return;
|
|
1428
|
+
if (opts.verbose === true) logger = createLogger({ level: LogLevel.INFO });
|
|
1429
|
+
else logger = createLogger();
|
|
1430
|
+
if (typeof opts.config === "string" && opts.config.length > 0 && typeof presetManager.setConfigPath === "function") presetManager.setConfigPath(opts.config);
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
if (error instanceof Error && error.message.includes("Process exited")) throw error;
|
|
1433
|
+
handleError(error);
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
return { run };
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
//#endregion
|
|
1440
|
+
//#region src/index.ts
|
|
1441
|
+
/**
|
|
1442
|
+
* Main entry point
|
|
1443
|
+
* Launches the CLI application
|
|
1444
|
+
*/
|
|
1445
|
+
const main = async () => {
|
|
1446
|
+
const cli = createCli();
|
|
1447
|
+
try {
|
|
1448
|
+
await cli.run(process.argv.slice(2));
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
if (error instanceof Error) {
|
|
1451
|
+
console.error("Error:", error.message);
|
|
1452
|
+
if (process.env.VDE_DEBUG === "true") console.error(error.stack);
|
|
1453
|
+
} else console.error("An unexpected error occurred:", String(error));
|
|
1454
|
+
process.exit(1);
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
main();
|
|
1458
|
+
|
|
1459
|
+
//#endregion
|
|
1460
|
+
export { };
|
|
24
1461
|
//# sourceMappingURL=index.js.map
|