openwriter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared constants and utility functions for OpenWriter server.
3
+ * Both state.ts and documents.ts import from here to avoid duplication.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
6
+ import { join, isAbsolute, basename, dirname } from 'path';
7
+ import { homedir } from 'os';
8
+ import { randomUUID } from 'crypto';
9
+ export const DATA_DIR = join(homedir(), '.openwriter');
10
+ export const VERSIONS_DIR = join(DATA_DIR, '.versions');
11
+ export const WORKSPACES_DIR = join(DATA_DIR, '_workspaces');
12
+ export const CONFIG_FILE = join(DATA_DIR, 'config.json');
13
+ export const TEMP_PREFIX = '_untitled-';
14
+ export function ensureDataDir() {
15
+ if (!existsSync(DATA_DIR)) {
16
+ // One-time migration: rename ~/.superwriter/ → ~/.openwriter/
17
+ const legacyDir = join(homedir(), '.superwriter');
18
+ if (existsSync(legacyDir)) {
19
+ renameSync(legacyDir, DATA_DIR);
20
+ }
21
+ else {
22
+ mkdirSync(DATA_DIR, { recursive: true });
23
+ }
24
+ }
25
+ }
26
+ export function ensureWorkspacesDir() {
27
+ ensureDataDir();
28
+ if (!existsSync(WORKSPACES_DIR))
29
+ mkdirSync(WORKSPACES_DIR, { recursive: true });
30
+ }
31
+ export function sanitizeFilename(name) {
32
+ return name.replace(/[<>:"/\\|?*]/g, '-').trim() || 'Untitled';
33
+ }
34
+ export function filePathForTitle(title) {
35
+ return join(DATA_DIR, `${sanitizeFilename(title)}.md`);
36
+ }
37
+ export function tempFilePath() {
38
+ return join(DATA_DIR, `${TEMP_PREFIX}${randomUUID()}.md`);
39
+ }
40
+ // ---- Path resolution for external documents ----
41
+ /** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
42
+ export function resolveDocPath(filename) {
43
+ if (isAbsolute(filename) || /[/\\]/.test(filename))
44
+ return filename;
45
+ return join(DATA_DIR, filename);
46
+ }
47
+ /** Returns true if filename is a full path (not a simple basename in DATA_DIR). */
48
+ export function isExternalDoc(filename) {
49
+ if (isAbsolute(filename) || /[/\\]/.test(filename)) {
50
+ const resolved = isAbsolute(filename) ? filename : filename;
51
+ return !resolved.startsWith(DATA_DIR);
52
+ }
53
+ return false;
54
+ }
55
+ /** Extract basename from a path, or return as-is if already a basename. */
56
+ export function getDocBasename(filename) {
57
+ return basename(filename);
58
+ }
59
+ /** Extract parent directory name from a path. Returns empty string for basenames. */
60
+ export function getParentDirName(filename) {
61
+ if (!isAbsolute(filename) && !/[/\\]/.test(filename))
62
+ return '';
63
+ return basename(dirname(filename));
64
+ }
65
+ /** Generate an 8-char hex node ID for TipTap block nodes. */
66
+ export function generateNodeId() {
67
+ return randomUUID().replace(/-/g, '').slice(0, 8);
68
+ }
69
+ /** Leaf block types: text-containing blocks that get pending decorations. */
70
+ export const LEAF_BLOCK_TYPES = new Set(['paragraph', 'heading', 'codeBlock', 'horizontalRule', 'table', 'image']);
71
+ export function readConfig() {
72
+ ensureDataDir();
73
+ if (!existsSync(CONFIG_FILE))
74
+ return {};
75
+ try {
76
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ }
82
+ export function saveConfig(updates) {
83
+ ensureDataDir();
84
+ const current = readConfig();
85
+ const merged = { ...current, ...updates };
86
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
87
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Image upload and static serving for OpenWriter.
3
+ * Images are stored in {DATA_DIR}/_images/ and referenced as relative paths in markdown.
4
+ */
5
+ import { Router } from 'express';
6
+ import multer from 'multer';
7
+ import { existsSync, mkdirSync } from 'fs';
8
+ import { join, extname } from 'path';
9
+ import { randomUUID } from 'crypto';
10
+ import { DATA_DIR, ensureDataDir } from './helpers.js';
11
+ import express from 'express';
12
+ const IMAGES_DIR = join(DATA_DIR, '_images');
13
+ function ensureImagesDir() {
14
+ ensureDataDir();
15
+ if (!existsSync(IMAGES_DIR))
16
+ mkdirSync(IMAGES_DIR, { recursive: true });
17
+ }
18
+ const storage = multer.diskStorage({
19
+ destination: (_req, _file, cb) => {
20
+ ensureImagesDir();
21
+ cb(null, IMAGES_DIR);
22
+ },
23
+ filename: (_req, file, cb) => {
24
+ const ext = extname(file.originalname) || '.png';
25
+ cb(null, `${randomUUID().slice(0, 8)}${ext}`);
26
+ },
27
+ });
28
+ const upload = multer({
29
+ storage,
30
+ limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
31
+ fileFilter: (_req, file, cb) => {
32
+ if (file.mimetype.startsWith('image/')) {
33
+ cb(null, true);
34
+ }
35
+ else {
36
+ cb(new Error('Only image files are allowed'));
37
+ }
38
+ },
39
+ });
40
+ export function createImageRouter() {
41
+ const router = Router();
42
+ // Static serving for images
43
+ ensureImagesDir();
44
+ router.use('/_images', express.static(IMAGES_DIR));
45
+ // Upload endpoint
46
+ router.post('/api/upload-image', upload.single('image'), (req, res) => {
47
+ if (!req.file) {
48
+ res.status(400).json({ error: 'No image file provided' });
49
+ return;
50
+ }
51
+ const src = `/_images/${req.file.filename}`;
52
+ res.json({ src });
53
+ });
54
+ return router;
55
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Express server: serves built React app, WebSocket, orchestrates MCP.
3
+ */
4
+ import express from 'express';
5
+ import { createServer } from 'http';
6
+ import { createConnection } from 'net';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname, join } from 'path';
9
+ import { existsSync } from 'fs';
10
+ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastSyncStatus } from './ws.js';
11
+ import { startMcpServer, TOOL_REGISTRY, registerPluginTools } from './mcp.js';
12
+ import { startMcpClientServer } from './mcp-client.js';
13
+ import { load, save, getDocument, getTitle, getFilePath, getDocId, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocFilenames, getPendingDocCounts } from './state.js';
14
+ import { listDocuments, switchDocument, createDocument, deleteDocument, reloadDocument, updateDocumentTitle, openFile } from './documents.js';
15
+ import { createWorkspaceRouter } from './workspace-routes.js';
16
+ import { createLinkRouter } from './link-routes.js';
17
+ import { markdownToTiptap } from './markdown.js';
18
+ import { importGoogleDoc } from './gdoc-import.js';
19
+ import { createVersionRouter } from './version-routes.js';
20
+ import { createSyncRouter } from './sync-routes.js';
21
+ import { createImageRouter } from './image-upload.js';
22
+ import { createExportRouter } from './export-routes.js';
23
+ import { loadPlugins } from './plugin-loader.js';
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+ function isPortTaken(port) {
27
+ return new Promise((resolve) => {
28
+ const socket = createConnection({ port, host: '127.0.0.1' });
29
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
30
+ socket.once('error', () => { resolve(false); });
31
+ });
32
+ }
33
+ export async function startServer(options = {}) {
34
+ const port = options.port || 5050;
35
+ // Check if another instance already owns the port
36
+ const portTaken = await isPortTaken(port);
37
+ if (portTaken) {
38
+ console.error(`[OpenWriter] Port ${port} in use — entering client mode (proxying to existing server)`);
39
+ // Start client-mode MCP (proxies tool calls via HTTP)
40
+ startMcpClientServer(port).catch((err) => {
41
+ console.error('[MCP-Client] Failed to start:', err);
42
+ });
43
+ // Skip browser open — existing server already has it open
44
+ return;
45
+ }
46
+ // Load saved document
47
+ load();
48
+ const app = express();
49
+ app.use(express.json({ limit: '10mb' }));
50
+ // API routes for direct HTTP access (fallback if WS not available)
51
+ app.get('/api/status', (_req, res) => {
52
+ res.json(getStatus());
53
+ });
54
+ // MCP-over-HTTP: allows client-mode terminals to proxy tool calls
55
+ app.post('/api/mcp-call', async (req, res) => {
56
+ try {
57
+ const { tool: toolName, arguments: args } = req.body;
58
+ const tool = TOOL_REGISTRY.find((t) => t.name === toolName);
59
+ if (!tool) {
60
+ res.status(404).json({ error: `Unknown tool: ${toolName}` });
61
+ return;
62
+ }
63
+ const result = await tool.handler(args || {});
64
+ res.json(result);
65
+ }
66
+ catch (err) {
67
+ res.status(500).json({ content: [{ type: 'text', text: `Error: ${err.message}` }] });
68
+ }
69
+ });
70
+ app.get('/api/document', (_req, res) => {
71
+ res.json({ document: getDocument(), title: getTitle() });
72
+ });
73
+ app.get('/api/pending-docs', (_req, res) => {
74
+ res.json({
75
+ filenames: getPendingDocFilenames(),
76
+ counts: getPendingDocCounts(),
77
+ });
78
+ });
79
+ // Mount image upload + static serving
80
+ app.use(createImageRouter());
81
+ // Mount sync routes
82
+ app.use(createSyncRouter(broadcastSyncStatus));
83
+ // Mount export routes
84
+ app.use(createExportRouter());
85
+ // Mount version history routes
86
+ app.use(createVersionRouter({
87
+ getDocId,
88
+ getFilePath,
89
+ updateDocument,
90
+ save,
91
+ broadcastDocumentSwitched,
92
+ }));
93
+ app.post('/api/save', (_req, res) => {
94
+ save();
95
+ res.json({ success: true });
96
+ });
97
+ // Beacon-based flush: browser sends this on beforeunload/visibilitychange
98
+ // sendBeacon sends as text/plain, so we parse the JSON manually
99
+ app.post('/api/flush', express.text({ type: '*/*', limit: '10mb' }), (req, res) => {
100
+ try {
101
+ if (isAgentLocked()) {
102
+ console.log('[Flush] Blocked (agent write lock active)');
103
+ res.status(204).end();
104
+ return;
105
+ }
106
+ const msg = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
107
+ if (msg.document) {
108
+ updateDocument(msg.document);
109
+ save();
110
+ }
111
+ else if (msg.markdown) {
112
+ const parsed = markdownToTiptap(msg.markdown);
113
+ updateDocument(parsed.document);
114
+ if (parsed.title !== 'Untitled')
115
+ setMetadata({ title: parsed.title });
116
+ save();
117
+ }
118
+ res.status(204).end();
119
+ }
120
+ catch {
121
+ res.status(400).end();
122
+ }
123
+ });
124
+ // Document CRUD routes
125
+ app.get('/api/documents', (_req, res) => {
126
+ res.json(listDocuments());
127
+ });
128
+ app.post('/api/documents', (req, res) => {
129
+ try {
130
+ const result = createDocument(req.body.title, req.body.content, req.body.path);
131
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
132
+ res.json(result);
133
+ }
134
+ catch (err) {
135
+ res.status(400).json({ error: err.message });
136
+ }
137
+ });
138
+ app.post('/api/documents/open', (req, res) => {
139
+ try {
140
+ const { path } = req.body;
141
+ if (!path) {
142
+ res.status(400).json({ error: 'path is required' });
143
+ return;
144
+ }
145
+ const result = openFile(path);
146
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
147
+ res.json(result);
148
+ }
149
+ catch (err) {
150
+ res.status(400).json({ error: err.message });
151
+ }
152
+ });
153
+ app.post('/api/documents/switch', (req, res) => {
154
+ try {
155
+ const result = switchDocument(req.body.filename);
156
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
157
+ res.json(result);
158
+ }
159
+ catch (err) {
160
+ res.status(404).json({ error: err.message });
161
+ }
162
+ });
163
+ app.post('/api/documents/reload', (_req, res) => {
164
+ try {
165
+ const result = reloadDocument();
166
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
167
+ res.json(result);
168
+ }
169
+ catch (err) {
170
+ res.status(500).json({ error: err.message });
171
+ }
172
+ });
173
+ app.delete('/api/documents/:filename', (req, res) => {
174
+ try {
175
+ const result = deleteDocument(req.params.filename);
176
+ if (result.switched && result.newDoc) {
177
+ broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
178
+ }
179
+ else {
180
+ broadcastDocumentsChanged();
181
+ }
182
+ res.json(result);
183
+ }
184
+ catch (err) {
185
+ res.status(400).json({ error: err.message });
186
+ }
187
+ });
188
+ app.put('/api/documents/:filename', (req, res) => {
189
+ try {
190
+ // Title change = metadata only. Filename stays stable.
191
+ updateDocumentTitle(req.params.filename, req.body.title);
192
+ broadcastDocumentsChanged();
193
+ res.json({ filename: req.params.filename });
194
+ }
195
+ catch (err) {
196
+ res.status(400).json({ error: err.message });
197
+ }
198
+ });
199
+ // Mount workspace CRUD + doc/container/tag routes
200
+ app.use(createWorkspaceRouter({ broadcastWorkspacesChanged }));
201
+ // Mount link-doc routes (create-link-doc, auto-tag-link)
202
+ app.use(createLinkRouter({ broadcastDocumentsChanged, broadcastWorkspacesChanged }));
203
+ // Text edit (fine-grained find/replace + mark changes within a node)
204
+ app.post('/api/edit-text', (req, res) => {
205
+ try {
206
+ const { nodeId, edits } = req.body;
207
+ if (!nodeId || !edits) {
208
+ res.status(400).json({ error: 'nodeId and edits are required' });
209
+ return;
210
+ }
211
+ const result = applyTextEdits(nodeId, edits);
212
+ if (!result.success) {
213
+ res.status(400).json({ error: result.error });
214
+ return;
215
+ }
216
+ res.json({ success: true });
217
+ }
218
+ catch (err) {
219
+ res.status(500).json({ error: err.message });
220
+ }
221
+ });
222
+ // Google Doc import
223
+ app.post('/api/import/gdoc', (req, res) => {
224
+ try {
225
+ const result = importGoogleDoc(req.body.document, req.body.title);
226
+ broadcastDocumentsChanged();
227
+ broadcastWorkspacesChanged();
228
+ res.json(result);
229
+ }
230
+ catch (err) {
231
+ res.status(400).json({ error: err.message });
232
+ }
233
+ });
234
+ // Load plugins
235
+ const pluginResult = await loadPlugins(options.plugins || [], options.pluginConfig || {});
236
+ for (const err of pluginResult.errors)
237
+ console.error(`[Plugin] ${err}`);
238
+ for (const { plugin, config } of pluginResult.plugins) {
239
+ if (plugin.registerRoutes)
240
+ await plugin.registerRoutes({ app, config });
241
+ if (plugin.mcpTools)
242
+ registerPluginTools(plugin.mcpTools(config));
243
+ console.log(`[Plugin] Loaded: ${plugin.name} v${plugin.version}`);
244
+ }
245
+ // Plugin discovery endpoint — client fetches to build dynamic context menu
246
+ app.get('/api/plugins', (_req, res) => {
247
+ const descriptors = pluginResult.plugins.map(({ plugin }) => ({
248
+ name: plugin.name,
249
+ contextMenuItems: plugin.contextMenuItems?.() || [],
250
+ }));
251
+ res.json({ plugins: descriptors });
252
+ });
253
+ // Plugin action dispatch — client sends action payload, routed to correct plugin
254
+ app.post('/api/plugin-action', async (req, res) => {
255
+ try {
256
+ const payload = req.body;
257
+ const prefix = payload.action.split(':')[0];
258
+ const loaded = pluginResult.plugins.find(({ plugin }) => plugin.contextMenuItems?.().some((item) => item.action.startsWith(prefix + ':')));
259
+ if (!loaded) {
260
+ res.status(404).json({ error: `No plugin handles action "${payload.action}"` });
261
+ return;
262
+ }
263
+ // Forward to plugin's registered route — plugins handle their own actions
264
+ // via routes registered in registerRoutes(). The client calls those directly.
265
+ res.status(404).json({ error: 'Use plugin-registered routes directly' });
266
+ }
267
+ catch (err) {
268
+ res.status(500).json({ error: err.message });
269
+ }
270
+ });
271
+ // Serve built React app
272
+ const clientDir = join(__dirname, '..', 'client');
273
+ if (existsSync(clientDir)) {
274
+ app.use(express.static(clientDir));
275
+ app.get('*', (_req, res) => {
276
+ res.sendFile(join(clientDir, 'index.html'));
277
+ });
278
+ }
279
+ else {
280
+ // Dev mode: proxy to Vite
281
+ app.get('/', (_req, res) => {
282
+ res.send(`
283
+ <html>
284
+ <body style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">
285
+ <div style="text-align:center">
286
+ <h2>OpenWriter Server Running</h2>
287
+ <p>In development, run <code>npm run dev:client</code> and visit <a href="http://localhost:5173">localhost:5173</a></p>
288
+ </div>
289
+ </body>
290
+ </html>
291
+ `);
292
+ });
293
+ }
294
+ const server = createServer(app);
295
+ // Setup WebSocket on same server
296
+ setupWebSocket(server);
297
+ // Start MCP stdio server (for agent connections)
298
+ startMcpServer().then(() => {
299
+ broadcastAgentStatus(true);
300
+ }).catch((err) => {
301
+ console.error('[MCP] Failed to start:', err);
302
+ });
303
+ server.listen(port, () => {
304
+ console.log(`OpenWriter running at http://localhost:${port}`);
305
+ });
306
+ // Open browser unless --no-open or running as MCP stdio pipe
307
+ const isMcpStdio = !process.stdout.isTTY;
308
+ if (!options.noOpen && !isMcpStdio) {
309
+ const open = await import('open');
310
+ const url = existsSync(clientDir)
311
+ ? `http://localhost:${port}`
312
+ : 'http://localhost:5173';
313
+ open.default(url).catch(() => { });
314
+ }
315
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Express routes for document linking: create-link-doc and auto-tag-link.
3
+ * Mounted in index.ts to keep the main file lean.
4
+ */
5
+ import { Router } from 'express';
6
+ import { existsSync, writeFileSync } from 'fs';
7
+ import { listWorkspaces, getWorkspace, addDoc, addContainerToWorkspace, tagDoc } from './workspaces.js';
8
+ import { collectAllFiles } from './workspace-tree.js';
9
+ import { getActiveFilename } from './documents.js';
10
+ import { filePathForTitle, ensureDataDir } from './helpers.js';
11
+ import { tiptapToMarkdown } from './markdown.js';
12
+ export function createLinkRouter(b) {
13
+ const router = Router();
14
+ // Create a new doc, link-ready, auto-organized into workspace "Linked" container
15
+ router.post('/api/create-link-doc', (req, res) => {
16
+ try {
17
+ const { title } = req.body;
18
+ if (!title?.trim()) {
19
+ res.status(400).json({ error: 'title is required' });
20
+ return;
21
+ }
22
+ // 1. Create the .md file without switching active document
23
+ ensureDataDir();
24
+ const filePath = filePathForTitle(title.trim());
25
+ const filename = filePath.split(/[/\\]/).pop();
26
+ if (existsSync(filePath)) {
27
+ res.status(409).json({ error: 'Document already exists', filename });
28
+ return;
29
+ }
30
+ const emptyDoc = { type: 'doc', content: [{ type: 'paragraph', content: [] }] };
31
+ const metadata = { title: title.trim() };
32
+ const markdown = tiptapToMarkdown(emptyDoc, title.trim(), metadata);
33
+ writeFileSync(filePath, markdown, 'utf-8');
34
+ // 2. Find workspaces containing the current (source) doc
35
+ const currentFilename = getActiveFilename();
36
+ const allWorkspaces = listWorkspaces();
37
+ for (const wsInfo of allWorkspaces) {
38
+ try {
39
+ const ws = getWorkspace(wsInfo.filename);
40
+ const wsFiles = collectAllFiles(ws.root);
41
+ if (!wsFiles.includes(currentFilename))
42
+ continue;
43
+ // 3. Ensure "Linked" container exists (find or create at bottom)
44
+ let linkedContainerId = null;
45
+ for (const node of ws.root) {
46
+ if (node.type === 'container' && node.name === 'Linked') {
47
+ linkedContainerId = node.id;
48
+ break;
49
+ }
50
+ }
51
+ if (!linkedContainerId) {
52
+ const result = addContainerToWorkspace(wsInfo.filename, null, 'Linked');
53
+ linkedContainerId = result.containerId;
54
+ }
55
+ // 4. Add new doc to "Linked" container
56
+ try {
57
+ addDoc(wsInfo.filename, linkedContainerId, filename, title.trim());
58
+ }
59
+ catch {
60
+ // Doc may already be in workspace (e.g., duplicate call)
61
+ }
62
+ // 5. Tag with "linked"
63
+ try {
64
+ tagDoc(wsInfo.filename, filename, 'linked');
65
+ }
66
+ catch {
67
+ // Doc not in workspace or already tagged
68
+ }
69
+ }
70
+ catch {
71
+ // Skip problematic workspaces
72
+ }
73
+ }
74
+ b.broadcastDocumentsChanged();
75
+ b.broadcastWorkspacesChanged();
76
+ res.json({ filename, title: title.trim() });
77
+ }
78
+ catch (err) {
79
+ res.status(500).json({ error: err.message });
80
+ }
81
+ });
82
+ // Auto-tag an existing doc with "linked" in shared workspaces
83
+ router.post('/api/auto-tag-link', (req, res) => {
84
+ try {
85
+ const { targetFile } = req.body;
86
+ if (!targetFile) {
87
+ res.status(400).json({ error: 'targetFile is required' });
88
+ return;
89
+ }
90
+ const currentFilename = getActiveFilename();
91
+ const allWorkspaces = listWorkspaces();
92
+ let tagged = false;
93
+ for (const wsInfo of allWorkspaces) {
94
+ try {
95
+ const ws = getWorkspace(wsInfo.filename);
96
+ const wsFiles = collectAllFiles(ws.root);
97
+ // Both source and target must be in same workspace
98
+ if (!wsFiles.includes(currentFilename) || !wsFiles.includes(targetFile))
99
+ continue;
100
+ tagDoc(wsInfo.filename, targetFile, 'linked');
101
+ tagged = true;
102
+ }
103
+ catch {
104
+ // Skip
105
+ }
106
+ }
107
+ if (tagged)
108
+ b.broadcastWorkspacesChanged();
109
+ res.json({ success: true, tagged });
110
+ }
111
+ catch (err) {
112
+ res.status(500).json({ error: err.message });
113
+ }
114
+ });
115
+ return router;
116
+ }