@velvetmonkey/flywheel-crank 0.4.0 → 0.5.1

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 (2) hide show
  1. package/dist/index.js +135 -12
  2. package/package.json +12 -7
package/dist/index.js CHANGED
@@ -16,6 +16,61 @@ import matter from "gray-matter";
16
16
  var HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
17
17
 
18
18
  // src/core/writer.ts
19
+ var SENSITIVE_PATH_PATTERNS = [
20
+ /\.env($|\..*)/i,
21
+ // .env, .env.local, .env.production, etc.
22
+ /\.git\/config$/i,
23
+ // Git config (may contain tokens)
24
+ /\.git\/credentials$/i,
25
+ // Git credentials
26
+ /\.pem$/i,
27
+ // SSL/TLS certificates
28
+ /\.key$/i,
29
+ // Private keys
30
+ /\.p12$/i,
31
+ // PKCS#12 certificates
32
+ /\.pfx$/i,
33
+ // Windows certificate format
34
+ /\.jks$/i,
35
+ // Java keystore
36
+ /id_rsa/i,
37
+ // SSH private key
38
+ /id_ed25519/i,
39
+ // SSH private key (ed25519)
40
+ /id_ecdsa/i,
41
+ // SSH private key (ecdsa)
42
+ /credentials\.json$/i,
43
+ // Cloud credentials files
44
+ /secrets\.json$/i,
45
+ // Secrets files
46
+ /secrets\.ya?ml$/i,
47
+ // Secrets YAML files
48
+ /\.htpasswd$/i,
49
+ // Apache password file
50
+ /shadow$/,
51
+ // Unix shadow password file
52
+ /passwd$/
53
+ // Unix password file
54
+ ];
55
+ function isSensitivePath(filePath) {
56
+ const normalizedPath = filePath.replace(/\\/g, "/");
57
+ return SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(normalizedPath));
58
+ }
59
+ function detectLineEnding(content) {
60
+ const crlfCount = (content.match(/\r\n/g) || []).length;
61
+ const lfCount = (content.match(/(?<!\r)\n/g) || []).length;
62
+ return crlfCount > lfCount ? "CRLF" : "LF";
63
+ }
64
+ function normalizeLineEndings(content) {
65
+ return content.replace(/\r\n/g, "\n");
66
+ }
67
+ function convertLineEndings(content, style) {
68
+ const normalized = content.replace(/\r\n/g, "\n");
69
+ return style === "CRLF" ? normalized.replace(/\n/g, "\r\n") : normalized;
70
+ }
71
+ function normalizeTrailingNewline(content) {
72
+ return content.replace(/[\r\n\s]+$/, "") + "\n";
73
+ }
19
74
  var EMPTY_PLACEHOLDER_PATTERNS = [
20
75
  /^\d+\.\s*$/,
21
76
  // "1. " or "2. " (numbered list placeholder)
@@ -169,25 +224,88 @@ function validatePath(vaultPath2, notePath) {
169
224
  const resolvedNote = path.resolve(vaultPath2, notePath);
170
225
  return resolvedNote.startsWith(resolvedVault);
171
226
  }
227
+ async function validatePathSecure(vaultPath2, notePath) {
228
+ const resolvedVault = path.resolve(vaultPath2);
229
+ const resolvedNote = path.resolve(vaultPath2, notePath);
230
+ if (!resolvedNote.startsWith(resolvedVault)) {
231
+ return {
232
+ valid: false,
233
+ reason: "Path traversal not allowed"
234
+ };
235
+ }
236
+ if (isSensitivePath(notePath)) {
237
+ return {
238
+ valid: false,
239
+ reason: "Cannot write to sensitive file (credentials, keys, secrets)"
240
+ };
241
+ }
242
+ try {
243
+ const fullPath = path.join(vaultPath2, notePath);
244
+ try {
245
+ await fs.access(fullPath);
246
+ const realPath = await fs.realpath(fullPath);
247
+ const realVaultPath = await fs.realpath(vaultPath2);
248
+ if (!realPath.startsWith(realVaultPath)) {
249
+ return {
250
+ valid: false,
251
+ reason: "Symlink target is outside vault"
252
+ };
253
+ }
254
+ const relativePath = path.relative(realVaultPath, realPath);
255
+ if (isSensitivePath(relativePath)) {
256
+ return {
257
+ valid: false,
258
+ reason: "Symlink target is a sensitive file"
259
+ };
260
+ }
261
+ } catch {
262
+ const parentDir = path.dirname(fullPath);
263
+ try {
264
+ await fs.access(parentDir);
265
+ const realParentPath = await fs.realpath(parentDir);
266
+ const realVaultPath = await fs.realpath(vaultPath2);
267
+ if (!realParentPath.startsWith(realVaultPath)) {
268
+ return {
269
+ valid: false,
270
+ reason: "Parent directory symlink target is outside vault"
271
+ };
272
+ }
273
+ } catch {
274
+ }
275
+ }
276
+ } catch (error) {
277
+ return {
278
+ valid: false,
279
+ reason: `Path validation error: ${error.message}`
280
+ };
281
+ }
282
+ return { valid: true };
283
+ }
172
284
  async function readVaultFile(vaultPath2, notePath) {
173
285
  if (!validatePath(vaultPath2, notePath)) {
174
286
  throw new Error("Invalid path: path traversal not allowed");
175
287
  }
176
288
  const fullPath = path.join(vaultPath2, notePath);
177
289
  const rawContent = await fs.readFile(fullPath, "utf-8");
178
- const parsed = matter(rawContent);
290
+ const lineEnding = detectLineEnding(rawContent);
291
+ const normalizedContent = normalizeLineEndings(rawContent);
292
+ const parsed = matter(normalizedContent);
179
293
  return {
180
294
  content: parsed.content,
181
295
  frontmatter: parsed.data,
182
- rawContent
296
+ rawContent,
297
+ lineEnding
183
298
  };
184
299
  }
185
- async function writeVaultFile(vaultPath2, notePath, content, frontmatter) {
186
- if (!validatePath(vaultPath2, notePath)) {
187
- throw new Error("Invalid path: path traversal not allowed");
300
+ async function writeVaultFile(vaultPath2, notePath, content, frontmatter, lineEnding = "LF") {
301
+ const validation = await validatePathSecure(vaultPath2, notePath);
302
+ if (!validation.valid) {
303
+ throw new Error(`Invalid path: ${validation.reason}`);
188
304
  }
189
305
  const fullPath = path.join(vaultPath2, notePath);
190
- const output = matter.stringify(content, frontmatter);
306
+ let output = matter.stringify(content, frontmatter);
307
+ output = normalizeTrailingNewline(output);
308
+ output = convertLineEndings(output, lineEnding);
191
309
  await fs.writeFile(fullPath, output, "utf-8");
192
310
  }
193
311
  function removeFromSection(content, section, pattern, mode = "first", useRegex = false) {
@@ -591,12 +709,15 @@ function suggestRelatedLinks(content, options = {}) {
591
709
  const linkedEntities = excludeLinked ? extractLinkedEntities(content) : /* @__PURE__ */ new Set();
592
710
  const scoredEntities = [];
593
711
  for (const entity of entities) {
594
- if (linkedEntities.has(entity.name.toLowerCase())) {
712
+ const entityName = typeof entity === "string" ? entity : entity.name;
713
+ if (!entityName)
714
+ continue;
715
+ if (linkedEntities.has(entityName.toLowerCase())) {
595
716
  continue;
596
717
  }
597
- const score = scoreEntity(entity.name, contentTokens);
718
+ const score = scoreEntity(entityName, contentTokens);
598
719
  if (score > 0) {
599
- scoredEntities.push({ name: entity.name, score });
720
+ scoredEntities.push({ name: entityName, score });
600
721
  }
601
722
  }
602
723
  scoredEntities.sort((a, b) => b.score - a.score);
@@ -1029,9 +1150,10 @@ function registerTaskTools(server2, vaultPath2) {
1029
1150
  completed: z2.boolean().default(false).describe("Whether the task should start as completed"),
1030
1151
  commit: z2.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
1031
1152
  skipWikilinks: z2.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
1032
- suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.')
1153
+ suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
1154
+ preserveListNesting: z2.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true")
1033
1155
  },
1034
- async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks }) => {
1156
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, preserveListNesting }) => {
1035
1157
  try {
1036
1158
  const fullPath = path5.join(vaultPath2, notePath);
1037
1159
  try {
@@ -1069,7 +1191,8 @@ function registerTaskTools(server2, vaultPath2) {
1069
1191
  fileContent,
1070
1192
  sectionBoundary,
1071
1193
  taskLine,
1072
- position
1194
+ position,
1195
+ { preserveListNesting }
1073
1196
  );
1074
1197
  await writeVaultFile(vaultPath2, notePath, updatedContent, frontmatter);
1075
1198
  let gitCommit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,13 +17,18 @@
17
17
  "homepage": "https://github.com/velvetmonkey/flywheel-crank#readme",
18
18
  "author": "velvetmonkey",
19
19
  "keywords": [
20
- "obsidian",
21
20
  "mcp",
22
- "model-context-protocol",
21
+ "mcp-server",
22
+ "obsidian",
23
+ "vault-management",
24
+ "markdown",
23
25
  "claude",
24
- "mutations",
25
- "vault-writes",
26
- "markdown"
26
+ "deterministic",
27
+ "git-integration",
28
+ "wikilinks",
29
+ "knowledge-graph",
30
+ "ai-agents",
31
+ "mutation-tools"
27
32
  ],
28
33
  "scripts": {
29
34
  "build": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external && chmod +x dist/index.js",
@@ -50,7 +55,7 @@
50
55
  "engines": {
51
56
  "node": ">=18.0.0"
52
57
  },
53
- "license": "AGPL-3.0",
58
+ "license": "Apache-2.0",
54
59
  "files": [
55
60
  "dist",
56
61
  "README.md",