@drewpayment/mink 0.1.0 → 0.2.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.
@@ -0,0 +1,203 @@
1
+ import { join } from "path";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { atomicWriteText } from "./fs-utils";
4
+ import { categoryToDir, vaultDailyDir, vaultTemplates } from "./vault";
5
+ import { loadTemplate } from "./vault-templates";
6
+ import type { NoteMetadata, NoteFrontmatter, NoteCategory } from "../types/note";
7
+
8
+ export function slugifyTitle(title: string): string {
9
+ return title
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9\s-]/g, "")
12
+ .replace(/\s+/g, "-")
13
+ .replace(/-+/g, "-")
14
+ .replace(/^-|-$/g, "")
15
+ .slice(0, 80);
16
+ }
17
+
18
+ export function generateFrontmatter(meta: {
19
+ created: string;
20
+ updated: string;
21
+ tags: string[];
22
+ category: NoteCategory;
23
+ sourceProject?: string;
24
+ aliases?: string[];
25
+ extra?: Record<string, unknown>;
26
+ }): string {
27
+ const lines: string[] = ["---"];
28
+ lines.push(`created: "${meta.created}"`);
29
+ lines.push(`updated: "${meta.updated}"`);
30
+
31
+ if (meta.tags.length > 0) {
32
+ lines.push(`tags: [${meta.tags.join(", ")}]`);
33
+ } else {
34
+ lines.push("tags: []");
35
+ }
36
+
37
+ lines.push(`category: ${meta.category}`);
38
+
39
+ if (meta.sourceProject) {
40
+ lines.push(`source_project: ${meta.sourceProject}`);
41
+ }
42
+
43
+ if (meta.aliases && meta.aliases.length > 0) {
44
+ lines.push(`aliases: [${meta.aliases.join(", ")}]`);
45
+ }
46
+
47
+ if (meta.extra) {
48
+ for (const [key, value] of Object.entries(meta.extra)) {
49
+ lines.push(`${key}: ${JSON.stringify(value)}`);
50
+ }
51
+ }
52
+
53
+ lines.push("---");
54
+ return lines.join("\n");
55
+ }
56
+
57
+ export function createNote(meta: NoteMetadata): {
58
+ filePath: string;
59
+ content: string;
60
+ } {
61
+ const now = meta.created || new Date().toISOString();
62
+ const slug = slugifyTitle(meta.title);
63
+ const dir = categoryToDir(meta.category, meta.projectSlug);
64
+ const filePath = join(dir, `${slug}.md`);
65
+
66
+ let content: string;
67
+
68
+ if (meta.template) {
69
+ const rendered = loadTemplate(vaultTemplates(), meta.template, {
70
+ title: meta.title,
71
+ body: meta.body,
72
+ created: now,
73
+ updated: now,
74
+ date: now.split("T")[0],
75
+ });
76
+ content = rendered ?? buildNoteContent(meta, now);
77
+ } else {
78
+ content = buildNoteContent(meta, now);
79
+ }
80
+
81
+ atomicWriteText(filePath, content);
82
+ return { filePath, content };
83
+ }
84
+
85
+ function buildNoteContent(meta: NoteMetadata, now: string): string {
86
+ const frontmatter = generateFrontmatter({
87
+ created: now,
88
+ updated: now,
89
+ tags: meta.tags,
90
+ category: meta.category,
91
+ sourceProject: meta.sourceProject,
92
+ });
93
+
94
+ return `${frontmatter}
95
+
96
+ # ${meta.title}
97
+
98
+ ${meta.body}
99
+ `;
100
+ }
101
+
102
+ export function appendToDaily(date: string, content: string): string {
103
+ const dir = vaultDailyDir();
104
+ const filePath = join(dir, `${date}.md`);
105
+
106
+ if (existsSync(filePath)) {
107
+ const existing = readFileSync(filePath, "utf-8");
108
+ const timestamp = new Date().toLocaleTimeString("en-US", {
109
+ hour: "2-digit",
110
+ minute: "2-digit",
111
+ hour12: false,
112
+ });
113
+ const updated = `${existing.trimEnd()}\n\n## ${timestamp}\n\n${content}\n`;
114
+ atomicWriteText(filePath, updated);
115
+ } else {
116
+ const now = new Date().toISOString();
117
+ const rendered = loadTemplate(vaultTemplates(), "daily-note", {
118
+ title: date,
119
+ date,
120
+ body: content,
121
+ created: now,
122
+ updated: now,
123
+ });
124
+ const noteContent =
125
+ rendered ??
126
+ `---
127
+ created: "${now}"
128
+ updated: "${now}"
129
+ tags: [daily]
130
+ category: areas
131
+ ---
132
+
133
+ # ${date}
134
+
135
+ ${content}
136
+ `;
137
+ atomicWriteText(filePath, noteContent);
138
+ }
139
+
140
+ return filePath;
141
+ }
142
+
143
+ export function ingestFile(
144
+ sourcePath: string,
145
+ meta: {
146
+ category: NoteCategory;
147
+ tags?: string[];
148
+ projectSlug?: string;
149
+ sourceProject?: string;
150
+ }
151
+ ): { filePath: string; content: string } {
152
+ const raw = readFileSync(sourcePath, "utf-8");
153
+ const now = new Date().toISOString();
154
+
155
+ // Extract title from first heading or filename
156
+ const headingMatch = raw.match(/^#\s+(.+)$/m);
157
+ const title =
158
+ headingMatch?.[1] ??
159
+ sourcePath
160
+ .split("/")
161
+ .pop()!
162
+ .replace(/\.md$/, "");
163
+
164
+ // Check if file already has frontmatter
165
+ const hasFrontmatter = raw.startsWith("---");
166
+ let content: string;
167
+
168
+ if (hasFrontmatter) {
169
+ // Preserve existing frontmatter, add missing fields
170
+ const endIdx = raw.indexOf("---", 3);
171
+ if (endIdx !== -1) {
172
+ const existingFm = raw.slice(0, endIdx + 3);
173
+ const body = raw.slice(endIdx + 3).trim();
174
+ // Add category if missing
175
+ if (!existingFm.includes("category:")) {
176
+ const updatedFm = existingFm.replace(
177
+ /---$/,
178
+ `category: ${meta.category}\n---`
179
+ );
180
+ content = `${updatedFm}\n\n${body}\n`;
181
+ } else {
182
+ content = raw;
183
+ }
184
+ } else {
185
+ content = raw;
186
+ }
187
+ } else {
188
+ const frontmatter = generateFrontmatter({
189
+ created: now,
190
+ updated: now,
191
+ tags: meta.tags ?? [],
192
+ category: meta.category,
193
+ sourceProject: meta.sourceProject,
194
+ });
195
+ content = `${frontmatter}\n\n${raw}`;
196
+ }
197
+
198
+ const slug = slugifyTitle(title);
199
+ const dir = categoryToDir(meta.category, meta.projectSlug);
200
+ const filePath = join(dir, `${slug}.md`);
201
+ atomicWriteText(filePath, content);
202
+ return { filePath, content };
203
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Cross-runtime utilities — Bun-first with Node.js fallbacks.
3
+ *
4
+ * Detects the runtime once at import time and exports helpers that
5
+ * abstract over Bun.serve / node:http, Bun.file / fs, and Bun.spawn / child_process.
6
+ */
7
+
8
+ import { readFile, stat } from "fs/promises";
9
+ import { spawn as nodeSpawn } from "child_process";
10
+
11
+ export const isBun = typeof globalThis.Bun !== "undefined";
12
+
13
+ // ── File helpers ──────────────────────────────────────────────────────────
14
+
15
+ export interface RuntimeFile {
16
+ exists(): Promise<boolean>;
17
+ bytes(): Promise<Uint8Array>;
18
+ }
19
+
20
+ /**
21
+ * Returns a lightweight file handle with `.exists()` and `.bytes()`.
22
+ * Uses Bun.file when available, otherwise falls back to fs.
23
+ */
24
+ export function runtimeFile(path: string): RuntimeFile {
25
+ if (isBun) {
26
+ const f = Bun.file(path);
27
+ return {
28
+ exists: () => f.exists(),
29
+ bytes: () => f.arrayBuffer().then((ab) => new Uint8Array(ab)),
30
+ };
31
+ }
32
+ return {
33
+ async exists() {
34
+ try {
35
+ await stat(path);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ },
41
+ async bytes() {
42
+ return readFile(path);
43
+ },
44
+ };
45
+ }
46
+
47
+ // ── Spawn helper ──────────────────────────────────────────────────────────
48
+
49
+ export interface SpawnOptions {
50
+ cwd?: string;
51
+ env?: Record<string, string | undefined>;
52
+ stdout?: "ignore" | "pipe";
53
+ stderr?: "ignore" | "pipe";
54
+ stdin?: "ignore";
55
+ }
56
+
57
+ export interface SpawnedProcess {
58
+ unref(): void;
59
+ }
60
+
61
+ /**
62
+ * Fire-and-forget process spawning. Uses Bun.spawn when available,
63
+ * otherwise child_process.spawn with detached + unref.
64
+ */
65
+ export function runtimeSpawn(
66
+ cmd: string[],
67
+ opts: SpawnOptions = {}
68
+ ): SpawnedProcess {
69
+ if (isBun) {
70
+ const proc = Bun.spawn(cmd, {
71
+ cwd: opts.cwd,
72
+ env: opts.env,
73
+ stdout: opts.stdout ?? "ignore",
74
+ stderr: opts.stderr ?? "ignore",
75
+ stdin: opts.stdin ?? "ignore",
76
+ });
77
+ return { unref: () => proc.unref() };
78
+ }
79
+
80
+ const [bin, ...args] = cmd;
81
+ const proc = nodeSpawn(bin, args, {
82
+ cwd: opts.cwd,
83
+ env: opts.env as NodeJS.ProcessEnv,
84
+ stdio: [
85
+ opts.stdin ?? "ignore",
86
+ opts.stdout ?? "ignore",
87
+ opts.stderr ?? "ignore",
88
+ ],
89
+ detached: true,
90
+ });
91
+ proc.unref();
92
+ return { unref: () => {} };
93
+ }
94
+
95
+ // ── HTTP Server ───────────────────────────────────────────────────────────
96
+
97
+ export interface RuntimeServer {
98
+ port: number;
99
+ stop(closeConnections?: boolean): void;
100
+ }
101
+
102
+ type FetchHandler = (req: Request) => Response | Promise<Response>;
103
+
104
+ interface ServeOptions {
105
+ port: number;
106
+ hostname: string;
107
+ idleTimeout?: number;
108
+ fetch: FetchHandler;
109
+ }
110
+
111
+ /**
112
+ * Start an HTTP server using Bun.serve or node:http.
113
+ * The fetch handler uses standard Web API Request/Response in both runtimes.
114
+ */
115
+ export async function runtimeServe(opts: ServeOptions): Promise<RuntimeServer> {
116
+ if (isBun) {
117
+ const server = Bun.serve({
118
+ port: opts.port,
119
+ hostname: opts.hostname,
120
+ idleTimeout: opts.idleTimeout ?? 0,
121
+ fetch: opts.fetch,
122
+ });
123
+ return {
124
+ port: server.port as number,
125
+ stop: (close) => server.stop(close),
126
+ };
127
+ }
128
+
129
+ // Node.js fallback using node:http
130
+ const { createServer } = await import("node:http");
131
+ const { Readable } = await import("node:stream");
132
+
133
+ return new Promise<RuntimeServer>((resolve) => {
134
+ const httpServer = createServer(async (req, res) => {
135
+ // Build a Web API Request from the Node IncomingMessage
136
+ const url = `http://${opts.hostname}:${opts.port}${req.url ?? "/"}`;
137
+ const headers = new Headers();
138
+ for (const [key, val] of Object.entries(req.headers)) {
139
+ if (val) headers.set(key, Array.isArray(val) ? val.join(", ") : val);
140
+ }
141
+
142
+ let body: BodyInit | null = null;
143
+ if (req.method !== "GET" && req.method !== "HEAD") {
144
+ const chunks: Buffer[] = [];
145
+ for await (const chunk of req) {
146
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
147
+ }
148
+ body = Buffer.concat(chunks);
149
+ }
150
+
151
+ const request = new Request(url, {
152
+ method: req.method,
153
+ headers,
154
+ body,
155
+ // @ts-expect-error -- Node 18+ supports duplex on Request
156
+ duplex: body ? "half" : undefined,
157
+ });
158
+
159
+ try {
160
+ const response = await opts.fetch(request);
161
+
162
+ res.writeHead(response.status, Object.fromEntries(response.headers));
163
+
164
+ if (!response.body) {
165
+ res.end();
166
+ return;
167
+ }
168
+
169
+ // Stream the response body
170
+ const reader = response.body.getReader();
171
+ const nodeStream = new Readable({
172
+ async read() {
173
+ try {
174
+ const { done, value } = await reader.read();
175
+ if (done) {
176
+ this.push(null);
177
+ } else {
178
+ this.push(Buffer.from(value));
179
+ }
180
+ } catch {
181
+ this.push(null);
182
+ }
183
+ },
184
+ });
185
+
186
+ // Clean up SSE streams when client disconnects
187
+ res.on("close", () => {
188
+ reader.cancel().catch(() => {});
189
+ nodeStream.destroy();
190
+ });
191
+
192
+ nodeStream.pipe(res);
193
+ } catch (err) {
194
+ if (!res.headersSent) {
195
+ res.writeHead(500);
196
+ res.end(String(err));
197
+ }
198
+ }
199
+ });
200
+
201
+ httpServer.listen(opts.port, opts.hostname, () => {
202
+ const addr = httpServer.address();
203
+ const boundPort =
204
+ typeof addr === "object" && addr ? addr.port : opts.port;
205
+ resolve({
206
+ port: boundPort,
207
+ stop: (close) => {
208
+ if (close) httpServer.closeAllConnections();
209
+ httpServer.close();
210
+ },
211
+ });
212
+ });
213
+ });
214
+ }
@@ -0,0 +1,179 @@
1
+ import { join } from "path";
2
+ import { existsSync, writeFileSync, readFileSync, mkdirSync } from "fs";
3
+
4
+ export const DEFAULT_TEMPLATES: Record<string, string> = {
5
+ "quick-capture": `---
6
+ created: "{{created}}"
7
+ updated: "{{updated}}"
8
+ tags: []
9
+ category: inbox
10
+ ---
11
+
12
+ # {{title}}
13
+
14
+ {{body}}
15
+ `,
16
+
17
+ "daily-note": `---
18
+ created: "{{created}}"
19
+ updated: "{{updated}}"
20
+ tags: [daily]
21
+ category: areas
22
+ ---
23
+
24
+ # {{date}}
25
+
26
+ ## Focus
27
+
28
+ -
29
+
30
+ ## Notes
31
+
32
+ -
33
+
34
+ ## Tasks
35
+
36
+ - [ ]
37
+
38
+ ## Reflections
39
+
40
+ `,
41
+
42
+ meeting: `---
43
+ created: "{{created}}"
44
+ updated: "{{updated}}"
45
+ tags: [meeting]
46
+ category: areas
47
+ ---
48
+
49
+ # {{title}}
50
+
51
+ **Date**: {{date}}
52
+ **Attendees**:
53
+
54
+ ## Agenda
55
+
56
+ -
57
+
58
+ ## Discussion
59
+
60
+ -
61
+
62
+ ## Decisions
63
+
64
+ -
65
+
66
+ ## Action Items
67
+
68
+ - [ ]
69
+ `,
70
+
71
+ project: `---
72
+ created: "{{created}}"
73
+ updated: "{{updated}}"
74
+ tags: [project]
75
+ category: projects
76
+ status: active
77
+ ---
78
+
79
+ # {{title}}
80
+
81
+ ## Overview
82
+
83
+ {{body}}
84
+
85
+ ## Goals
86
+
87
+ -
88
+
89
+ ## Key Decisions
90
+
91
+ -
92
+
93
+ ## Links
94
+
95
+ -
96
+ `,
97
+
98
+ area: `---
99
+ created: "{{created}}"
100
+ updated: "{{updated}}"
101
+ tags: [area]
102
+ category: areas
103
+ ---
104
+
105
+ # {{title}}
106
+
107
+ ## Purpose
108
+
109
+ {{body}}
110
+
111
+ ## Standards
112
+
113
+ -
114
+
115
+ ## Key Resources
116
+
117
+ -
118
+ `,
119
+
120
+ person: `---
121
+ created: "{{created}}"
122
+ updated: "{{updated}}"
123
+ tags: [person]
124
+ category: resources
125
+ ---
126
+
127
+ # {{title}}
128
+
129
+ ## Role
130
+
131
+ ## Context
132
+
133
+ ## 1:1 Notes
134
+
135
+ -
136
+
137
+ ## Key Projects
138
+
139
+ -
140
+ `,
141
+ };
142
+
143
+ export function seedTemplates(templatesDir: string): void {
144
+ mkdirSync(templatesDir, { recursive: true });
145
+ for (const [name, content] of Object.entries(DEFAULT_TEMPLATES)) {
146
+ const filePath = join(templatesDir, `${name}.md`);
147
+ if (!existsSync(filePath)) {
148
+ writeFileSync(filePath, content);
149
+ }
150
+ }
151
+ }
152
+
153
+ export function loadTemplate(
154
+ templatesDir: string,
155
+ templateName: string,
156
+ vars: Record<string, string>
157
+ ): string | null {
158
+ const filePath = join(templatesDir, `${templateName}.md`);
159
+ let content: string;
160
+ if (existsSync(filePath)) {
161
+ content = readFileSync(filePath, "utf-8");
162
+ } else if (DEFAULT_TEMPLATES[templateName]) {
163
+ content = DEFAULT_TEMPLATES[templateName];
164
+ } else {
165
+ return null;
166
+ }
167
+ return fillTemplate(content, vars);
168
+ }
169
+
170
+ export function fillTemplate(
171
+ template: string,
172
+ vars: Record<string, string>
173
+ ): string {
174
+ let result = template;
175
+ for (const [key, value] of Object.entries(vars)) {
176
+ result = result.replaceAll(`{{${key}}}`, value);
177
+ }
178
+ return result;
179
+ }
@@ -0,0 +1,132 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+ import { existsSync, mkdirSync } from "fs";
4
+ import { resolveConfigValue } from "./global-config";
5
+ import { safeReadJson } from "./fs-utils";
6
+ import type { VaultManifest } from "../types/note";
7
+
8
+ const DEFAULT_VAULT_PATH = join(homedir(), ".mink", "wiki");
9
+
10
+ export function resolveVaultPath(): string {
11
+ const resolved = resolveConfigValue("wiki.path");
12
+ const raw = resolved.value;
13
+ if (raw.startsWith("~/")) {
14
+ return join(homedir(), raw.slice(2));
15
+ }
16
+ return raw;
17
+ }
18
+
19
+ export function vaultRoot(): string {
20
+ return resolveVaultPath();
21
+ }
22
+
23
+ export function vaultInbox(): string {
24
+ return join(resolveVaultPath(), "inbox");
25
+ }
26
+
27
+ export function vaultProjects(slug?: string): string {
28
+ const base = join(resolveVaultPath(), "projects");
29
+ return slug ? join(base, slug) : base;
30
+ }
31
+
32
+ export function vaultAreas(): string {
33
+ return join(resolveVaultPath(), "areas");
34
+ }
35
+
36
+ export function vaultDailyDir(): string {
37
+ return join(resolveVaultPath(), "areas", "daily");
38
+ }
39
+
40
+ export function vaultResources(): string {
41
+ return join(resolveVaultPath(), "resources");
42
+ }
43
+
44
+ export function vaultArchives(): string {
45
+ return join(resolveVaultPath(), "archives");
46
+ }
47
+
48
+ export function vaultTemplates(): string {
49
+ return join(resolveVaultPath(), "templates");
50
+ }
51
+
52
+ export function vaultPatterns(): string {
53
+ return join(resolveVaultPath(), "patterns");
54
+ }
55
+
56
+ export function vaultManifestPath(): string {
57
+ return join(resolveVaultPath(), ".mink-vault.json");
58
+ }
59
+
60
+ export function vaultIndexPath(): string {
61
+ return join(resolveVaultPath(), ".mink-index.json");
62
+ }
63
+
64
+ export function vaultMasterIndexPath(): string {
65
+ return join(resolveVaultPath(), "_index.md");
66
+ }
67
+
68
+ export function isVaultInitialized(): boolean {
69
+ return existsSync(vaultManifestPath());
70
+ }
71
+
72
+ export function isInsideVault(cwd: string): boolean {
73
+ const vault = resolveVaultPath();
74
+ const normalizedCwd = cwd.replace(/\/+$/, "");
75
+ const normalizedVault = vault.replace(/\/+$/, "");
76
+ return (
77
+ normalizedCwd === normalizedVault ||
78
+ normalizedCwd.startsWith(normalizedVault + "/")
79
+ );
80
+ }
81
+
82
+ export function isWikiEnabled(): boolean {
83
+ const resolved = resolveConfigValue("wiki.enabled");
84
+ return resolved.value === "true";
85
+ }
86
+
87
+ export function loadVaultManifest(): VaultManifest | null {
88
+ const raw = safeReadJson(vaultManifestPath());
89
+ if (raw === null || typeof raw !== "object") return null;
90
+ return raw as VaultManifest;
91
+ }
92
+
93
+ const VAULT_DIRS = [
94
+ "",
95
+ "inbox",
96
+ "projects",
97
+ "areas",
98
+ "areas/daily",
99
+ "resources",
100
+ "archives",
101
+ "templates",
102
+ "patterns",
103
+ ];
104
+
105
+ export function ensureVaultStructure(): void {
106
+ const root = resolveVaultPath();
107
+ for (const dir of VAULT_DIRS) {
108
+ mkdirSync(join(root, dir), { recursive: true });
109
+ }
110
+ }
111
+
112
+ export function categoryToDir(
113
+ category: string,
114
+ projectSlug?: string
115
+ ): string {
116
+ const root = resolveVaultPath();
117
+ switch (category) {
118
+ case "projects":
119
+ return projectSlug
120
+ ? join(root, "projects", projectSlug)
121
+ : join(root, "projects");
122
+ case "areas":
123
+ return join(root, "areas");
124
+ case "resources":
125
+ return join(root, "resources");
126
+ case "archives":
127
+ return join(root, "archives");
128
+ case "inbox":
129
+ default:
130
+ return join(root, "inbox");
131
+ }
132
+ }