@yugabytedb/meko-mcp 1.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.
Files changed (51) hide show
  1. package/README.md +505 -0
  2. package/assets/hooks-handlers/lib/capture.js +863 -0
  3. package/assets/hooks-handlers/lib/checkpoint-timer.js +72 -0
  4. package/assets/hooks-handlers/pre-compact.sh +3 -0
  5. package/assets/hooks-handlers/session-end.sh +27 -0
  6. package/assets/hooks-handlers/session-start.sh +24 -0
  7. package/assets/hooks-handlers/test_single_conversation.sh +169 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +192 -0
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +31 -0
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +39 -0
  11. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +49 -0
  12. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +373 -0
  13. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +56 -0
  14. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +38 -0
  15. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +61 -0
  16. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +128 -0
  17. package/assets/skills/meko-mcp-tools/references/tools-overview.md +97 -0
  18. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +63 -0
  19. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +79 -0
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +143 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +93 -0
  22. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +30 -0
  23. package/bin/create-meko-setup.mjs +499 -0
  24. package/lib/claude-code.mjs +14 -0
  25. package/lib/clients/claude-code.mjs +265 -0
  26. package/lib/clients/claude-desktop.mjs +256 -0
  27. package/lib/clients/common.mjs +107 -0
  28. package/lib/clients/cursor.mjs +332 -0
  29. package/lib/clients/index.mjs +14 -0
  30. package/lib/config.mjs +369 -0
  31. package/lib/detect.mjs +125 -0
  32. package/lib/installer.mjs +255 -0
  33. package/lib/logger.mjs +97 -0
  34. package/lib/mcp-transport.mjs +204 -0
  35. package/lib/migrate/adapters/claude-code.mjs +165 -0
  36. package/lib/migrate/adapters/cursor.mjs +221 -0
  37. package/lib/migrate/adapters/memory-md.mjs +230 -0
  38. package/lib/migrate/claude-parser.mjs +306 -0
  39. package/lib/migrate/daemon.mjs +67 -0
  40. package/lib/migrate/id.mjs +83 -0
  41. package/lib/migrate/lockfile.mjs +121 -0
  42. package/lib/migrate/logger.mjs +79 -0
  43. package/lib/migrate/mcp-client.mjs +224 -0
  44. package/lib/migrate/orchestrator.mjs +443 -0
  45. package/lib/migrate/progress.mjs +76 -0
  46. package/lib/migrate/state.mjs +177 -0
  47. package/lib/migrate.mjs +328 -0
  48. package/lib/prompt.mjs +212 -0
  49. package/lib/validate.mjs +259 -0
  50. package/package.json +49 -0
  51. package/scripts/bundle-assets.mjs +49 -0
package/lib/prompt.mjs ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * prompt.mjs - Interactive terminal prompts using Node.js readline.
3
+ *
4
+ * Lazy-initializes the readline interface on first use so that importing
5
+ * this module has no side-effects (no stdin/stdout hijacking until needed).
6
+ * Zero external dependencies - Node.js built-ins only.
7
+ */
8
+
9
+ import { createInterface } from "node:readline";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Lazy readline singleton
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** @type {import('node:readline').Interface | null} */
16
+ let rl = null;
17
+
18
+ function getRL() {
19
+ if (!rl) {
20
+ rl = createInterface({
21
+ input: process.stdin,
22
+ output: process.stdout,
23
+ });
24
+ }
25
+ return rl;
26
+ }
27
+
28
+ /**
29
+ * Thin promise wrapper around `rl.question`.
30
+ * @param {string} query - The prompt text shown to the user.
31
+ * @returns {Promise<string>}
32
+ */
33
+ function question(query) {
34
+ return new Promise((resolve) => {
35
+ getRL().question(query, (answer) => resolve(answer));
36
+ });
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // askChoice
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Display a numbered list and return the selected option's label.
45
+ *
46
+ * @param {string} questionText
47
+ * @param {{ label: string, description?: string }[]} options
48
+ * @returns {Promise<string>} The `label` of the chosen option.
49
+ *
50
+ * @example
51
+ * const choice = await askChoice('Connection type:', [
52
+ * { label: 'Cloud Meko', description: 'Hosted at mekodev.com' },
53
+ * { label: 'Local Meko', description: 'docker-compose stack' },
54
+ * ]);
55
+ */
56
+ export async function askChoice(questionText, options) {
57
+ console.log(`\n ${questionText}`);
58
+ for (let i = 0; i < options.length; i++) {
59
+ const opt = options[i];
60
+ const desc = opt.description ? ` - ${opt.description}` : "";
61
+ console.log(` ${i + 1}) ${opt.label}${desc}`);
62
+ }
63
+
64
+ while (true) {
65
+ const raw = await question(` Choice [1-${options.length}]: `);
66
+ const idx = parseInt(raw.trim(), 10);
67
+ if (idx >= 1 && idx <= options.length) {
68
+ return options[idx - 1].label;
69
+ }
70
+ console.log(` Please enter a number between 1 and ${options.length}.`);
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // askText
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Free-text input with an optional default shown in brackets.
80
+ *
81
+ * @param {string} questionText
82
+ * @param {string} [defaultValue]
83
+ * @returns {Promise<string>}
84
+ */
85
+ export async function askText(questionText, defaultValue) {
86
+ const suffix = defaultValue != null ? ` [${defaultValue}]` : "";
87
+ const raw = await question(` ${questionText}${suffix} `);
88
+ const trimmed = raw.trim();
89
+ return trimmed || defaultValue || "";
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // askSecret
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Masked input that echoes `*` for each character typed.
98
+ *
99
+ * Puts stdin into raw mode so we can intercept individual keystrokes.
100
+ * Handles backspace, Ctrl-C (abort), and Enter (submit).
101
+ *
102
+ * @param {string} questionText
103
+ * @returns {Promise<string>}
104
+ */
105
+ export async function askSecret(questionText) {
106
+ // Print the prompt without a trailing newline.
107
+ process.stdout.write(` ${questionText} `);
108
+
109
+ return new Promise((resolve, reject) => {
110
+ let secret = "";
111
+ const stdin = process.stdin;
112
+
113
+ // We need raw mode to capture keystrokes individually.
114
+ // Guard: raw mode is only available on TTY streams.
115
+ if (!stdin.isTTY) {
116
+ // Fallback: non-TTY environments (piped input) - read a plain line.
117
+ const fallbackRL = createInterface({ input: stdin, output: process.stdout });
118
+ fallbackRL.question("", (answer) => {
119
+ fallbackRL.close();
120
+ resolve(answer.trim());
121
+ });
122
+ return;
123
+ }
124
+
125
+ const wasRaw = stdin.isRaw;
126
+ stdin.setRawMode(true);
127
+ stdin.resume();
128
+ stdin.setEncoding("utf8");
129
+
130
+ /** Restore stdin state and print a newline after the masked input. */
131
+ function cleanup() {
132
+ stdin.setRawMode(wasRaw);
133
+ stdin.removeListener("data", onData);
134
+ process.stdout.write("\n");
135
+ }
136
+
137
+ function onData(ch) {
138
+ const code = ch.charCodeAt(0);
139
+
140
+ // Ctrl-C - abort gracefully
141
+ if (ch === "\u0003") {
142
+ cleanup();
143
+ reject(new Error("User cancelled"));
144
+ return;
145
+ }
146
+
147
+ // Enter / Return
148
+ if (ch === "\r" || ch === "\n") {
149
+ cleanup();
150
+ resolve(secret);
151
+ return;
152
+ }
153
+
154
+ // Backspace / Delete (char code 127 or 8)
155
+ if (code === 127 || code === 8) {
156
+ if (secret.length > 0) {
157
+ secret = secret.slice(0, -1);
158
+ // Erase the last asterisk: move cursor back, overwrite, move back.
159
+ process.stdout.write("\b \b");
160
+ }
161
+ return;
162
+ }
163
+
164
+ // Ignore other control characters (arrows, etc.)
165
+ if (code < 32) {
166
+ return;
167
+ }
168
+
169
+ // Normal printable character
170
+ secret += ch;
171
+ process.stdout.write("*");
172
+ }
173
+
174
+ stdin.on("data", onData);
175
+ });
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // askYesNo
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Y/n confirmation prompt. Returns a boolean.
184
+ *
185
+ * @param {string} questionText
186
+ * @param {boolean} [defaultYes=true] - When true the prompt shows `[Y/n]`,
187
+ * otherwise `[y/N]`. Pressing Enter without input uses the default.
188
+ * @returns {Promise<boolean>}
189
+ */
190
+ export async function askYesNo(questionText, defaultYes = true) {
191
+ const hint = defaultYes ? "Y/n" : "y/N";
192
+ const raw = await question(` ${questionText} [${hint}] `);
193
+ const trimmed = raw.trim().toLowerCase();
194
+
195
+ if (trimmed === "") return defaultYes;
196
+ return trimmed === "y" || trimmed === "yes";
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // close
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Tear down the readline interface so the process can exit cleanly.
205
+ * Safe to call multiple times or before any prompt was used.
206
+ */
207
+ export function close() {
208
+ if (rl) {
209
+ rl.close();
210
+ rl = null;
211
+ }
212
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * validate.mjs - Post-install validation for Meko MCP Server.
3
+ *
4
+ * Runs three checks in sequence:
5
+ * 1. Config check - Is the server registered in `claude mcp list`?
6
+ * 2. Connectivity - Can we complete an MCP `initialize` handshake?
7
+ * 3. Tools - Does `tools/list` return the expected core tools?
8
+ *
9
+ * HTTP transport lives in ./mcp-transport.mjs and is shared with the
10
+ * migrate subcommand.
11
+ */
12
+
13
+ import { execFile } from "node:child_process";
14
+ import {
15
+ jsonRpcRequest,
16
+ classifyError,
17
+ mcpInitialize,
18
+ DEFAULT_REQUEST_TIMEOUT_MS,
19
+ } from "./mcp-transport.mjs";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Core tools that must appear in tools/list for validation to pass. */
26
+ const CORE_TOOLS = [
27
+ "memory_search",
28
+ "conversation_create",
29
+ "db_summarize_database",
30
+ ];
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // 1. Config validation
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Verify that a named MCP server appears in `claude mcp list` output.
38
+ *
39
+ * @param {string} serverName - The server name to look for (e.g. "meko").
40
+ * @returns {Promise<{ok: boolean, message: string}>}
41
+ */
42
+ export async function validateConfig(serverName) {
43
+ try {
44
+ const stdout = await execAsync("claude", ["mcp", "list"]);
45
+ // `claude mcp list` outputs one server per line, name as the first token.
46
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
47
+ const found = lines.some((line) => {
48
+ const name = line.split(":")[0].trim();
49
+ return name === serverName;
50
+ });
51
+
52
+ if (found) {
53
+ return { ok: true, message: `Server "${serverName}" is registered.` };
54
+ }
55
+ return {
56
+ ok: false,
57
+ message:
58
+ `Server "${serverName}" not found in \`claude mcp list\` output. ` +
59
+ "Run the installer again or add it manually.",
60
+ };
61
+ } catch (err) {
62
+ return {
63
+ ok: false,
64
+ message: `Failed to run \`claude mcp list\`: ${err.message}`,
65
+ };
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // 2. Connectivity validation
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Attempt an MCP `initialize` handshake against the server URL.
75
+ *
76
+ * @param {string} url - Full MCP endpoint URL (e.g. http://localhost:8000/mcp).
77
+ * @param {string|null} apiKey - Optional API key (sent as `API_KEY` header).
78
+ * @returns {Promise<{ok: boolean, message: string, sessionId?: string}>}
79
+ */
80
+ export async function validateConnectivity(url, apiKey) {
81
+ const result = await mcpInitialize(url, apiKey);
82
+ if (!result.ok) {
83
+ return { ok: false, message: result.message };
84
+ }
85
+ return {
86
+ ok: true,
87
+ message: `MCP server reachable at ${url}`,
88
+ sessionId: result.sessionId,
89
+ };
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // 3. Tools validation
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Send `tools/list` and verify that core Meko tools are present.
98
+ *
99
+ * Performs its own initialize handshake first to establish a session,
100
+ * then sends tools/list within that session.
101
+ *
102
+ * @param {string} url - Full MCP endpoint URL.
103
+ * @param {string|null} apiKey - Optional API key.
104
+ * @returns {Promise<{ok: boolean, tools: string[], message: string}>}
105
+ */
106
+ export async function validateTools(url, apiKey) {
107
+ // Establish a session first.
108
+ const connResult = await validateConnectivity(url, apiKey);
109
+ if (!connResult.ok) {
110
+ return {
111
+ ok: false,
112
+ tools: [],
113
+ message: `Cannot list tools: ${connResult.message}`,
114
+ };
115
+ }
116
+
117
+ return sendToolsList(url, apiKey, connResult.sessionId);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // 4. Combined runner
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Run all validation checks in sequence and return a summary.
126
+ *
127
+ * When the connectivity check succeeds, the tools check reuses the same
128
+ * MCP session rather than performing a redundant second initialize.
129
+ *
130
+ * @param {string} url - Full MCP endpoint URL.
131
+ * @param {string|null} apiKey - Optional API key.
132
+ * @param {string} serverName - Server name for config check (default "meko").
133
+ * @param {object} [options]
134
+ * @param {(params: {serverName: string}) => Promise<{ok: boolean, message: string}>} [options.validateConfig]
135
+ * @returns {Promise<{config: object, connectivity: object, tools: object, allPassed: boolean}>}
136
+ */
137
+ export async function runValidation(url, apiKey, serverName = "meko", options = {}) {
138
+ const configValidator = options.validateConfig ?? (async ({ serverName: value }) => validateConfig(value));
139
+ const config = await configValidator({ serverName });
140
+ const connectivity = await validateConnectivity(url, apiKey);
141
+
142
+ let tools;
143
+ if (connectivity.ok) {
144
+ // Reuse the session from the connectivity check.
145
+ tools = await sendToolsList(url, apiKey, connectivity.sessionId);
146
+ } else {
147
+ tools = { ok: false, tools: [], message: `Skipped: ${connectivity.message}` };
148
+ }
149
+
150
+ return {
151
+ config,
152
+ connectivity,
153
+ tools,
154
+ allPassed: config.ok && connectivity.ok && tools.ok,
155
+ };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Internal: tools/list over an existing session
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Send a tools/list request using an existing (or null) session ID.
164
+ *
165
+ * @param {string} url
166
+ * @param {string|null} apiKey
167
+ * @param {string|null} sessionId
168
+ * @returns {Promise<{ok: boolean, tools: string[], message: string}>}
169
+ */
170
+ async function sendToolsList(url, apiKey, sessionId) {
171
+ const payload = {
172
+ jsonrpc: "2.0",
173
+ id: 2,
174
+ method: "tools/list",
175
+ params: {},
176
+ };
177
+
178
+ let res;
179
+ try {
180
+ res = await jsonRpcRequest(url, payload, apiKey, sessionId);
181
+ } catch (err) {
182
+ return {
183
+ ok: false,
184
+ tools: [],
185
+ message: `tools/list request failed: ${classifyError(err)}`,
186
+ };
187
+ }
188
+
189
+ if (res.statusCode < 200 || res.statusCode >= 300) {
190
+ return {
191
+ ok: false,
192
+ tools: [],
193
+ message: `tools/list returned HTTP ${res.statusCode}: ${res.body}`,
194
+ };
195
+ }
196
+
197
+ let parsed;
198
+ try {
199
+ parsed = JSON.parse(res.body);
200
+ } catch {
201
+ return {
202
+ ok: false,
203
+ tools: [],
204
+ message: `tools/list returned non-JSON: ${res.body.slice(0, 200)}`,
205
+ };
206
+ }
207
+
208
+ if (parsed.error) {
209
+ return {
210
+ ok: false,
211
+ tools: [],
212
+ message: `tools/list error: ${parsed.error.message ?? JSON.stringify(parsed.error)}`,
213
+ };
214
+ }
215
+
216
+ const toolList = parsed.result?.tools ?? [];
217
+ const toolNames = toolList.map((t) => t.name);
218
+ const missing = CORE_TOOLS.filter((name) => !toolNames.includes(name));
219
+
220
+ if (missing.length > 0) {
221
+ return {
222
+ ok: false,
223
+ tools: toolNames,
224
+ message:
225
+ `Missing core tools: ${missing.join(", ")}. ` +
226
+ `Found ${toolNames.length} tool(s) total.`,
227
+ };
228
+ }
229
+
230
+ return {
231
+ ok: true,
232
+ tools: toolNames,
233
+ message: `All ${CORE_TOOLS.length} core tools present (${toolNames.length} total).`,
234
+ };
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Internal: child process helper
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Promisified child_process.execFile.
243
+ *
244
+ * @param {string} cmd - Executable name or path.
245
+ * @param {string[]} args - Arguments.
246
+ * @returns {Promise<string>} stdout
247
+ */
248
+ function execAsync(cmd, args) {
249
+ return new Promise((resolve, reject) => {
250
+ execFile(cmd, args, { timeout: DEFAULT_REQUEST_TIMEOUT_MS }, (err, stdout, stderr) => {
251
+ if (err) {
252
+ const msg = stderr?.trim() || err.message;
253
+ reject(new Error(msg));
254
+ return;
255
+ }
256
+ resolve(stdout);
257
+ });
258
+ });
259
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@yugabytedb/meko-mcp",
3
+ "version": "1.0.3",
4
+ "description": "Single-line installer for Meko MCP Server across Claude and Cursor clients",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-meko-setup": "bin/create-meko-setup.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "assets/",
13
+ "scripts/bundle-assets.mjs",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "prepack": "node scripts/bundle-assets.mjs",
18
+ "postpack": "node scripts/bundle-assets.mjs --clean",
19
+ "bundle-assets": "node scripts/bundle-assets.mjs",
20
+ "test": "node --test \"test/*.mjs\"",
21
+ "test:unit": "node --test test/config.test.mjs test/validate.test.mjs test/clients.test.mjs",
22
+ "test:integration": "RUN_INSTALLER_INTEGRATION=1 node --test test/installer.test.mjs",
23
+ "test:integration:cloud": "RUN_INSTALLER_INTEGRATION=1 node --test test/installer.test.mjs"
24
+ },
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "keywords": [
29
+ "meko",
30
+ "mcp",
31
+ "claude-code",
32
+ "yugabytedb",
33
+ "installer",
34
+ "ai-memory",
35
+ "model-context-protocol"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/yugabyte/meko-mcp-server.git",
40
+ "directory": "installer"
41
+ },
42
+ "homepage": "https://mekodata.netlify.app/",
43
+ "license": "Apache-2.0",
44
+ "author": "YugaByte <info@yugabyte.com>",
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ }
49
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copy skill/hook assets from the repo into installer/assets/ so the npm
4
+ * tarball can be installed without a repo checkout. Invoked by `prepack`;
5
+ * `postpack` re-runs this with --clean.
6
+ */
7
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
12
+ const installerRoot = resolve(scriptDir, "..");
13
+ const repoRoot = resolve(installerRoot, "..");
14
+ const assetsDir = join(installerRoot, "assets");
15
+
16
+ const PAYLOAD = [
17
+ {
18
+ from: join(repoRoot, "skills", "skills", "meko-mcp-tools"),
19
+ to: join(assetsDir, "skills", "meko-mcp-tools"),
20
+ },
21
+ {
22
+ from: join(repoRoot, "skills", "hooks-handlers"),
23
+ to: join(assetsDir, "hooks-handlers"),
24
+ },
25
+ ];
26
+
27
+ const cleaning = process.argv.includes("--clean");
28
+
29
+ if (cleaning) {
30
+ if (existsSync(assetsDir)) {
31
+ rmSync(assetsDir, { recursive: true, force: true });
32
+ }
33
+ process.exit(0);
34
+ }
35
+
36
+ if (existsSync(assetsDir)) {
37
+ rmSync(assetsDir, { recursive: true, force: true });
38
+ }
39
+ mkdirSync(assetsDir, { recursive: true });
40
+
41
+ for (const { from, to } of PAYLOAD) {
42
+ if (!existsSync(from)) {
43
+ console.error(`bundle-assets: missing source ${from}`);
44
+ process.exit(1);
45
+ }
46
+ cpSync(from, to, { recursive: true });
47
+ }
48
+
49
+ console.log(`bundle-assets: wrote ${PAYLOAD.length} paths under ${assetsDir}`);