openwriter 0.5.5 → 0.6.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.
- package/dist/bin/pad.js +3 -0
- package/dist/client/assets/index-CCMCrgTu.js +209 -0
- package/dist/client/assets/index-DN1_4Au6.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/connection-routes.js +327 -0
- package/dist/server/connections.js +35 -0
- package/dist/server/documents.js +30 -24
- package/dist/server/export-html-template.js +1 -1
- package/dist/server/export-routes.js +1 -1
- package/dist/server/gdoc-import.js +2 -2
- package/dist/server/git-sync.js +30 -30
- package/dist/server/helpers.js +175 -20
- package/dist/server/image-upload.js +10 -7
- package/dist/server/index.js +162 -5
- package/dist/server/install-skill.js +119 -2
- package/dist/server/marks.js +11 -11
- package/dist/server/mcp.js +90 -16
- package/dist/server/plugin-manager.js +2 -2
- package/dist/server/scheduler-routes.js +121 -0
- package/dist/server/state.js +126 -42
- package/dist/server/versions.js +6 -2
- package/dist/server/workspaces.js +7 -7
- package/dist/server/ws.js +15 -0
- package/package.json +1 -1
- package/skill/SKILL.md +106 -25
- package/dist/client/assets/index-97-SDxVw.js +0 -210
- package/dist/client/assets/index-BR_sMmFf.css +0 -1
package/dist/server/helpers.js
CHANGED
|
@@ -2,51 +2,78 @@
|
|
|
2
2
|
* Shared constants and utility functions for OpenWriter server.
|
|
3
3
|
* Both state.ts and documents.ts import from here to avoid duplication.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, rmSync } from 'fs';
|
|
6
6
|
import { join, isAbsolute, basename, dirname, resolve, sep } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
|
-
export const
|
|
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');
|
|
9
|
+
export const ROOT_DIR = join(homedir(), '.openwriter');
|
|
13
10
|
export const TEMP_PREFIX = '_untitled-';
|
|
11
|
+
// ---- Profile state ----
|
|
12
|
+
let activeProfile = 'Default';
|
|
13
|
+
export function getActiveProfile() {
|
|
14
|
+
return activeProfile;
|
|
15
|
+
}
|
|
16
|
+
export function setActiveProfile(name) {
|
|
17
|
+
activeProfile = name;
|
|
18
|
+
}
|
|
19
|
+
// ---- Profile-aware path getters ----
|
|
20
|
+
export function getDataDir() {
|
|
21
|
+
return join(ROOT_DIR, 'profiles', activeProfile);
|
|
22
|
+
}
|
|
23
|
+
export function getVersionsDir() {
|
|
24
|
+
return join(getDataDir(), '.versions');
|
|
25
|
+
}
|
|
26
|
+
export function getWorkspacesDir() {
|
|
27
|
+
return join(getDataDir(), '_workspaces');
|
|
28
|
+
}
|
|
29
|
+
export function getConfigFile() {
|
|
30
|
+
return join(ROOT_DIR, 'config.json');
|
|
31
|
+
}
|
|
32
|
+
// ---- Directory bootstrapping ----
|
|
14
33
|
export function ensureDataDir() {
|
|
15
|
-
|
|
16
|
-
|
|
34
|
+
// Ensure ROOT_DIR exists (with legacy migration)
|
|
35
|
+
if (!existsSync(ROOT_DIR)) {
|
|
17
36
|
const legacyDir = join(homedir(), '.superwriter');
|
|
18
37
|
if (existsSync(legacyDir)) {
|
|
19
|
-
renameSync(legacyDir,
|
|
38
|
+
renameSync(legacyDir, ROOT_DIR);
|
|
20
39
|
}
|
|
21
40
|
else {
|
|
22
|
-
mkdirSync(
|
|
41
|
+
mkdirSync(ROOT_DIR, { recursive: true });
|
|
23
42
|
}
|
|
24
43
|
}
|
|
44
|
+
// Migrate flat files into profiles/Default/ if needed
|
|
45
|
+
migrateToProfiles();
|
|
46
|
+
// Ensure active profile directory exists
|
|
47
|
+
const dataDir = getDataDir();
|
|
48
|
+
if (!existsSync(dataDir))
|
|
49
|
+
mkdirSync(dataDir, { recursive: true });
|
|
25
50
|
}
|
|
26
51
|
export function ensureWorkspacesDir() {
|
|
27
52
|
ensureDataDir();
|
|
28
|
-
|
|
29
|
-
|
|
53
|
+
const wsDir = getWorkspacesDir();
|
|
54
|
+
if (!existsSync(wsDir))
|
|
55
|
+
mkdirSync(wsDir, { recursive: true });
|
|
30
56
|
}
|
|
31
57
|
export function sanitizeFilename(name) {
|
|
32
58
|
return name.replace(/[<>:"/\\|?*]/g, '-').trim() || 'Untitled';
|
|
33
59
|
}
|
|
34
60
|
export function filePathForTitle(title) {
|
|
35
|
-
return join(
|
|
61
|
+
return join(getDataDir(), `${sanitizeFilename(title)}.md`);
|
|
36
62
|
}
|
|
37
63
|
export function tempFilePath() {
|
|
38
|
-
return join(
|
|
64
|
+
return join(getDataDir(), `${TEMP_PREFIX}${randomUUID()}.md`);
|
|
39
65
|
}
|
|
40
66
|
// ---- Path resolution for external documents ----
|
|
41
67
|
/** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
|
|
42
68
|
export function resolveDocPath(filename) {
|
|
69
|
+
const dataDir = getDataDir();
|
|
43
70
|
// External docs use absolute paths as identifiers — pass through
|
|
44
71
|
if (isAbsolute(filename))
|
|
45
72
|
return filename;
|
|
46
73
|
// Internal docs must resolve within DATA_DIR — block path traversal
|
|
47
|
-
const resolved = resolve(
|
|
48
|
-
const
|
|
49
|
-
if (resolved !==
|
|
74
|
+
const resolved = resolve(dataDir, filename);
|
|
75
|
+
const dataDirResolved = resolve(dataDir);
|
|
76
|
+
if (resolved !== dataDirResolved && !resolved.startsWith(dataDirResolved + sep)) {
|
|
50
77
|
throw new Error(`Path traversal blocked: ${filename}`);
|
|
51
78
|
}
|
|
52
79
|
return resolved;
|
|
@@ -55,7 +82,7 @@ export function resolveDocPath(filename) {
|
|
|
55
82
|
export function isExternalDoc(filename) {
|
|
56
83
|
if (isAbsolute(filename) || /[/\\]/.test(filename)) {
|
|
57
84
|
const resolved = isAbsolute(filename) ? filename : filename;
|
|
58
|
-
return !resolved.startsWith(
|
|
85
|
+
return !resolved.startsWith(getDataDir());
|
|
59
86
|
}
|
|
60
87
|
return false;
|
|
61
88
|
}
|
|
@@ -83,10 +110,11 @@ export function generateNodeId() {
|
|
|
83
110
|
export const LEAF_BLOCK_TYPES = new Set(['paragraph', 'heading', 'codeBlock', 'horizontalRule', 'table', 'image']);
|
|
84
111
|
export function readConfig() {
|
|
85
112
|
ensureDataDir();
|
|
86
|
-
|
|
113
|
+
const configFile = getConfigFile();
|
|
114
|
+
if (!existsSync(configFile))
|
|
87
115
|
return {};
|
|
88
116
|
try {
|
|
89
|
-
return JSON.parse(readFileSync(
|
|
117
|
+
return JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
90
118
|
}
|
|
91
119
|
catch {
|
|
92
120
|
return {};
|
|
@@ -96,5 +124,132 @@ export function saveConfig(updates) {
|
|
|
96
124
|
ensureDataDir();
|
|
97
125
|
const current = readConfig();
|
|
98
126
|
const merged = { ...current, ...updates };
|
|
99
|
-
atomicWriteFileSync(
|
|
127
|
+
atomicWriteFileSync(getConfigFile(), JSON.stringify(merged, null, 2));
|
|
128
|
+
}
|
|
129
|
+
// ---- Profile CRUD ----
|
|
130
|
+
export function listProfiles() {
|
|
131
|
+
const config = readConfig();
|
|
132
|
+
return config.profiles || ['Default'];
|
|
133
|
+
}
|
|
134
|
+
export function createProfile(name) {
|
|
135
|
+
if (!name.trim())
|
|
136
|
+
throw new Error('Profile name cannot be empty');
|
|
137
|
+
const config = readConfig();
|
|
138
|
+
const profiles = config.profiles || ['Default'];
|
|
139
|
+
if (profiles.includes(name))
|
|
140
|
+
throw new Error(`Profile "${name}" already exists`);
|
|
141
|
+
// Create profile directory
|
|
142
|
+
const profileDir = join(ROOT_DIR, 'profiles', name);
|
|
143
|
+
mkdirSync(profileDir, { recursive: true });
|
|
144
|
+
// Update config
|
|
145
|
+
profiles.push(name);
|
|
146
|
+
saveConfig({ profiles });
|
|
147
|
+
}
|
|
148
|
+
export function deleteProfile(name) {
|
|
149
|
+
if (name === 'Default')
|
|
150
|
+
throw new Error('Cannot delete the Default profile');
|
|
151
|
+
if (name === activeProfile)
|
|
152
|
+
throw new Error('Cannot delete the active profile');
|
|
153
|
+
const config = readConfig();
|
|
154
|
+
const profiles = config.profiles || ['Default'];
|
|
155
|
+
const idx = profiles.indexOf(name);
|
|
156
|
+
if (idx === -1)
|
|
157
|
+
throw new Error(`Profile "${name}" not found`);
|
|
158
|
+
// Move directory to .trash-{name}/ (soft delete)
|
|
159
|
+
const profileDir = join(ROOT_DIR, 'profiles', name);
|
|
160
|
+
const trashDir = join(ROOT_DIR, 'profiles', `.trash-${name}`);
|
|
161
|
+
if (existsSync(profileDir)) {
|
|
162
|
+
// If a trash dir already exists for this name, remove the old trash first
|
|
163
|
+
if (existsSync(trashDir)) {
|
|
164
|
+
rmSync(trashDir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
renameSync(profileDir, trashDir);
|
|
167
|
+
}
|
|
168
|
+
profiles.splice(idx, 1);
|
|
169
|
+
saveConfig({ profiles });
|
|
170
|
+
}
|
|
171
|
+
export function listTrashedProfiles() {
|
|
172
|
+
const profilesDir = join(ROOT_DIR, 'profiles');
|
|
173
|
+
if (!existsSync(profilesDir))
|
|
174
|
+
return [];
|
|
175
|
+
try {
|
|
176
|
+
return readdirSync(profilesDir)
|
|
177
|
+
.filter(name => name.startsWith('.trash-') && statSync(join(profilesDir, name)).isDirectory())
|
|
178
|
+
.map(name => name.slice('.trash-'.length));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function restoreProfile(name) {
|
|
185
|
+
const trashDir = join(ROOT_DIR, 'profiles', `.trash-${name}`);
|
|
186
|
+
if (!existsSync(trashDir))
|
|
187
|
+
throw new Error(`No trashed profile "${name}" found`);
|
|
188
|
+
const profileDir = join(ROOT_DIR, 'profiles', name);
|
|
189
|
+
if (existsSync(profileDir))
|
|
190
|
+
throw new Error(`A profile named "${name}" already exists`);
|
|
191
|
+
renameSync(trashDir, profileDir);
|
|
192
|
+
// Add back to config
|
|
193
|
+
const config = readConfig();
|
|
194
|
+
const profiles = config.profiles || ['Default'];
|
|
195
|
+
if (!profiles.includes(name)) {
|
|
196
|
+
profiles.push(name);
|
|
197
|
+
saveConfig({ profiles });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ---- Migration: flat files → profiles/Default/ ----
|
|
201
|
+
function migrateToProfiles() {
|
|
202
|
+
const profilesDir = join(ROOT_DIR, 'profiles');
|
|
203
|
+
if (existsSync(profilesDir))
|
|
204
|
+
return; // Already migrated
|
|
205
|
+
// Create profiles/Default/
|
|
206
|
+
const defaultDir = join(profilesDir, 'Default');
|
|
207
|
+
mkdirSync(defaultDir, { recursive: true });
|
|
208
|
+
// Items to move from ROOT_DIR → profiles/Default/
|
|
209
|
+
const dirsToMove = ['_workspaces', '_images', '.versions', '_marks'];
|
|
210
|
+
const filesToMove = ['_doc-order.json', 'external-docs.json'];
|
|
211
|
+
for (const dir of dirsToMove) {
|
|
212
|
+
const src = join(ROOT_DIR, dir);
|
|
213
|
+
if (existsSync(src)) {
|
|
214
|
+
renameSync(src, join(defaultDir, dir));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const file of filesToMove) {
|
|
218
|
+
const src = join(ROOT_DIR, file);
|
|
219
|
+
if (existsSync(src)) {
|
|
220
|
+
renameSync(src, join(defaultDir, file));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Move .git and .gitignore
|
|
224
|
+
for (const name of ['.git', '.gitignore']) {
|
|
225
|
+
const src = join(ROOT_DIR, name);
|
|
226
|
+
if (existsSync(src)) {
|
|
227
|
+
renameSync(src, join(defaultDir, name));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Move all .md files
|
|
231
|
+
try {
|
|
232
|
+
const entries = readdirSync(ROOT_DIR);
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
if (entry.endsWith('.md')) {
|
|
235
|
+
const src = join(ROOT_DIR, entry);
|
|
236
|
+
if (statSync(src).isFile()) {
|
|
237
|
+
renameSync(src, join(defaultDir, entry));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch { /* best effort */ }
|
|
243
|
+
// Update config
|
|
244
|
+
const configFile = getConfigFile();
|
|
245
|
+
let config = {};
|
|
246
|
+
if (existsSync(configFile)) {
|
|
247
|
+
try {
|
|
248
|
+
config = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
249
|
+
}
|
|
250
|
+
catch { /* */ }
|
|
251
|
+
}
|
|
252
|
+
config.activeProfile = 'Default';
|
|
253
|
+
config.profiles = ['Default'];
|
|
254
|
+
atomicWriteFileSync(configFile, JSON.stringify(config, null, 2));
|
|
100
255
|
}
|
|
@@ -7,18 +7,19 @@ import multer from 'multer';
|
|
|
7
7
|
import { existsSync, mkdirSync } from 'fs';
|
|
8
8
|
import { join, extname } from 'path';
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
|
-
import {
|
|
10
|
+
import { getDataDir, ensureDataDir } from './helpers.js';
|
|
11
11
|
import express from 'express';
|
|
12
|
-
|
|
12
|
+
function getImagesDir() { return join(getDataDir(), '_images'); }
|
|
13
13
|
function ensureImagesDir() {
|
|
14
14
|
ensureDataDir();
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const dir = getImagesDir();
|
|
16
|
+
if (!existsSync(dir))
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
17
18
|
}
|
|
18
19
|
const storage = multer.diskStorage({
|
|
19
20
|
destination: (_req, _file, cb) => {
|
|
20
21
|
ensureImagesDir();
|
|
21
|
-
cb(null,
|
|
22
|
+
cb(null, getImagesDir());
|
|
22
23
|
},
|
|
23
24
|
filename: (_req, file, cb) => {
|
|
24
25
|
const ext = extname(file.originalname) || '.png';
|
|
@@ -39,9 +40,11 @@ const upload = multer({
|
|
|
39
40
|
});
|
|
40
41
|
export function createImageRouter() {
|
|
41
42
|
const router = Router();
|
|
42
|
-
//
|
|
43
|
+
// Dynamic static serving — resolves active profile's images dir per request
|
|
43
44
|
ensureImagesDir();
|
|
44
|
-
router.use('/_images',
|
|
45
|
+
router.use('/_images', (req, res, next) => {
|
|
46
|
+
express.static(getImagesDir())(req, res, next);
|
|
47
|
+
});
|
|
45
48
|
// Upload endpoint
|
|
46
49
|
router.post('/api/upload-image', upload.single('image'), (req, res) => {
|
|
47
50
|
if (!req.file) {
|
package/dist/server/index.js
CHANGED
|
@@ -11,19 +11,23 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
|
|
|
11
11
|
import { TOOL_REGISTRY } from './mcp.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
|
-
import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc } from './state.js';
|
|
15
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument } from './documents.js';
|
|
14
|
+
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, clearAllCaches } from './state.js';
|
|
15
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename } from './documents.js';
|
|
16
16
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
17
17
|
import { createLinkRouter } from './link-routes.js';
|
|
18
18
|
import { createTweetRouter } from './tweet-routes.js';
|
|
19
19
|
import { markdownToTiptap } from './markdown.js';
|
|
20
20
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
21
21
|
import { createVersionRouter } from './version-routes.js';
|
|
22
|
+
import { clearVersionsCache } from './versions.js';
|
|
22
23
|
import { createSyncRouter } from './sync-routes.js';
|
|
23
24
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
24
|
-
import { resolveDocPath } from './helpers.js';
|
|
25
|
+
import { resolveDocPath, getActiveProfile, setActiveProfile, listProfiles, createProfile, deleteProfile, listTrashedProfiles, restoreProfile, saveConfig, readConfig } from './helpers.js';
|
|
25
26
|
import { createImageRouter } from './image-upload.js';
|
|
26
27
|
import { createExportRouter } from './export-routes.js';
|
|
28
|
+
import { createConnectionRouter } from './connection-routes.js';
|
|
29
|
+
import { createSchedulerRouter } from './scheduler-routes.js';
|
|
30
|
+
import { platformFetch, isAuthenticated } from './connections.js';
|
|
27
31
|
import { PluginManager } from './plugin-manager.js';
|
|
28
32
|
import { checkForUpdate } from './update-check.js';
|
|
29
33
|
import { addMark, getMarks, resolveMarks } from './marks.js';
|
|
@@ -81,6 +85,40 @@ export async function startHttpServer(options = {}) {
|
|
|
81
85
|
app.use(createSyncRouter(broadcastSyncStatus));
|
|
82
86
|
// Mount export routes
|
|
83
87
|
app.use(createExportRouter());
|
|
88
|
+
// Mount connection CRUD + profile binding routes
|
|
89
|
+
app.use(createConnectionRouter());
|
|
90
|
+
// Mount scheduler proxy routes
|
|
91
|
+
app.use(createSchedulerRouter());
|
|
92
|
+
// Newsletter analytics proxy routes
|
|
93
|
+
app.get('/api/publications', async (req, res) => {
|
|
94
|
+
try {
|
|
95
|
+
if (!isAuthenticated()) {
|
|
96
|
+
res.status(401).json({ error: 'Not authenticated' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const qs = req.query.documentId ? `?documentId=${encodeURIComponent(req.query.documentId)}` : '';
|
|
100
|
+
const resp = await platformFetch(`/publications${qs}`);
|
|
101
|
+
const data = await resp.json();
|
|
102
|
+
res.status(resp.status).json(data);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
res.status(500).json({ error: err.message });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
app.get('/api/publications/:id/stats', async (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
if (!isAuthenticated()) {
|
|
111
|
+
res.status(401).json({ error: 'Not authenticated' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const resp = await platformFetch(`/publications/${req.params.id}/stats`);
|
|
115
|
+
const data = await resp.json();
|
|
116
|
+
res.status(resp.status).json(data);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
res.status(500).json({ error: err.message });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
84
122
|
// Mount version history routes
|
|
85
123
|
app.use(createVersionRouter({
|
|
86
124
|
getDocId,
|
|
@@ -136,6 +174,26 @@ export async function startHttpServer(options = {}) {
|
|
|
136
174
|
app.get('/api/documents', (_req, res) => {
|
|
137
175
|
res.json(listDocuments());
|
|
138
176
|
});
|
|
177
|
+
app.get('/api/documents/:filename/text', (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
const filepath = resolveDocPath(req.params.filename);
|
|
180
|
+
const raw = readFileSync(filepath, 'utf-8');
|
|
181
|
+
// Parse YAML frontmatter
|
|
182
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
183
|
+
const text = fmMatch ? fmMatch[2].trim() : raw.trim();
|
|
184
|
+
let meta = {};
|
|
185
|
+
if (fmMatch) {
|
|
186
|
+
try {
|
|
187
|
+
meta = JSON.parse(fmMatch[1]);
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
}
|
|
191
|
+
res.json({ text, meta });
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
res.status(404).json({ error: 'Document not found' });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
139
197
|
app.put('/api/documents/reorder', (req, res) => {
|
|
140
198
|
try {
|
|
141
199
|
const { order } = req.body;
|
|
@@ -209,10 +267,32 @@ export async function startHttpServer(options = {}) {
|
|
|
209
267
|
res.status(400).json({ error: err.message });
|
|
210
268
|
}
|
|
211
269
|
});
|
|
270
|
+
// Sync browser editor content to server state — guarantees server has latest before MCP calls.
|
|
271
|
+
// Used by compose modals that need to read server state via MCP tools.
|
|
272
|
+
app.post('/api/documents/sync-content', (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
const { document: doc, filename } = req.body;
|
|
275
|
+
if (!doc || !filename) {
|
|
276
|
+
res.status(400).json({ error: 'document and filename required' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (filename === getActiveFilename()) {
|
|
280
|
+
updateDocument(doc);
|
|
281
|
+
save();
|
|
282
|
+
}
|
|
283
|
+
res.json({ ok: true });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
res.status(500).json({ error: err.message });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
212
289
|
app.post('/api/documents/switch', (req, res) => {
|
|
213
290
|
try {
|
|
291
|
+
const alreadyActive = req.body.filename === getActiveFilename();
|
|
214
292
|
const result = switchDocument(req.body.filename);
|
|
215
|
-
|
|
293
|
+
if (!alreadyActive) {
|
|
294
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
295
|
+
}
|
|
216
296
|
res.json(result);
|
|
217
297
|
}
|
|
218
298
|
catch (err) {
|
|
@@ -400,6 +480,83 @@ export async function startHttpServer(options = {}) {
|
|
|
400
480
|
res.status(500).json({ error: err.message });
|
|
401
481
|
}
|
|
402
482
|
});
|
|
483
|
+
// ---- Profile management ----
|
|
484
|
+
app.get('/api/profiles', (_req, res) => {
|
|
485
|
+
res.json({ profiles: listProfiles(), active: getActiveProfile() });
|
|
486
|
+
});
|
|
487
|
+
app.post('/api/profiles', (req, res) => {
|
|
488
|
+
try {
|
|
489
|
+
const { name } = req.body;
|
|
490
|
+
if (!name?.trim()) {
|
|
491
|
+
res.status(400).json({ error: 'name is required' });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
createProfile(name.trim());
|
|
495
|
+
res.json({ success: true });
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
res.status(400).json({ error: err.message });
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
app.post('/api/profiles/switch', async (req, res) => {
|
|
502
|
+
try {
|
|
503
|
+
const { name } = req.body;
|
|
504
|
+
if (!name?.trim()) {
|
|
505
|
+
res.status(400).json({ error: 'name is required' });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const profiles = listProfiles();
|
|
509
|
+
if (!profiles.includes(name)) {
|
|
510
|
+
res.status(404).json({ error: `Profile "${name}" not found` });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// Flush current doc
|
|
514
|
+
cancelDebouncedSave();
|
|
515
|
+
save();
|
|
516
|
+
// Switch profile
|
|
517
|
+
setActiveProfile(name);
|
|
518
|
+
saveConfig({ activeProfile: name });
|
|
519
|
+
// Clear caches and reload
|
|
520
|
+
clearAllCaches();
|
|
521
|
+
clearVersionsCache();
|
|
522
|
+
load();
|
|
523
|
+
// Broadcast fresh state
|
|
524
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), getFilePath().split(/[/\\]/).pop() || '', getMetadata());
|
|
525
|
+
broadcastDocumentsChanged();
|
|
526
|
+
broadcastWorkspacesChanged();
|
|
527
|
+
broadcastPendingDocsChanged();
|
|
528
|
+
res.json({ success: true, active: name });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
res.status(500).json({ error: err.message });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
app.delete('/api/profiles/:name', (req, res) => {
|
|
535
|
+
try {
|
|
536
|
+
deleteProfile(req.params.name);
|
|
537
|
+
res.json({ success: true });
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
res.status(400).json({ error: err.message });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
app.get('/api/profiles/trash', (_req, res) => {
|
|
544
|
+
res.json({ profiles: listTrashedProfiles() });
|
|
545
|
+
});
|
|
546
|
+
app.post('/api/profiles/restore', (req, res) => {
|
|
547
|
+
try {
|
|
548
|
+
const { name } = req.body;
|
|
549
|
+
if (!name?.trim()) {
|
|
550
|
+
res.status(400).json({ error: 'name is required' });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
restoreProfile(name.trim());
|
|
554
|
+
res.json({ success: true });
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
res.status(400).json({ error: err.message });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
403
560
|
// Google Doc import
|
|
404
561
|
app.post('/api/import/gdoc', (req, res) => {
|
|
405
562
|
try {
|
|
@@ -422,7 +579,7 @@ export async function startHttpServer(options = {}) {
|
|
|
422
579
|
console.error(`[Plugin] ${result.error}`);
|
|
423
580
|
}
|
|
424
581
|
// Auto-enable from saved config.json
|
|
425
|
-
const savedConfig =
|
|
582
|
+
const savedConfig = readConfig();
|
|
426
583
|
for (const [name, state] of Object.entries(savedConfig.plugins || {})) {
|
|
427
584
|
if (state.enabled && !((options.plugins || []).includes(name))) {
|
|
428
585
|
const result = await pluginManager.enable(name);
|
|
@@ -1,19 +1,136 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
7
|
const __dirname = path.dirname(__filename);
|
|
8
|
+
function log(msg) {
|
|
9
|
+
console.error(msg);
|
|
10
|
+
}
|
|
11
|
+
function isGloballyInstalled() {
|
|
12
|
+
try {
|
|
13
|
+
const result = execSync('openwriter --version', {
|
|
14
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
15
|
+
timeout: 10000,
|
|
16
|
+
});
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function installGlobally() {
|
|
24
|
+
log('\n② Installing openwriter globally...');
|
|
25
|
+
// Try without sudo first (works on Windows, nvm, Homebrew, volta, etc.)
|
|
26
|
+
try {
|
|
27
|
+
execSync('npm install -g openwriter', { stdio: 'inherit', timeout: 120000 });
|
|
28
|
+
log(' ✓ Installed globally');
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Likely permission error on macOS/Linux system Node
|
|
33
|
+
}
|
|
34
|
+
// Try with sudo on non-Windows
|
|
35
|
+
if (process.platform !== 'win32') {
|
|
36
|
+
log(' Retrying with sudo...');
|
|
37
|
+
try {
|
|
38
|
+
execSync('sudo npm install -g openwriter', { stdio: 'inherit', timeout: 120000 });
|
|
39
|
+
log(' ✓ Installed globally (sudo)');
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// sudo failed too
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
log(' ✗ Could not install globally. Run manually:');
|
|
47
|
+
log(' npm install -g openwriter');
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
function isMcpConfigured() {
|
|
51
|
+
const claudeJson = path.join(os.homedir(), '.claude.json');
|
|
52
|
+
if (!fs.existsSync(claudeJson))
|
|
53
|
+
return false;
|
|
54
|
+
try {
|
|
55
|
+
const content = JSON.parse(fs.readFileSync(claudeJson, 'utf-8'));
|
|
56
|
+
return !!content?.mcpServers?.openwriter;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function configureMcp() {
|
|
63
|
+
log('\n③ Configuring MCP server for Claude Code...');
|
|
64
|
+
// Try using claude CLI
|
|
65
|
+
try {
|
|
66
|
+
execSync('claude mcp add -s user openwriter -- openwriter --no-open', {
|
|
67
|
+
stdio: 'inherit',
|
|
68
|
+
timeout: 15000,
|
|
69
|
+
});
|
|
70
|
+
log(' ✓ MCP server configured');
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// claude CLI not available or failed
|
|
75
|
+
}
|
|
76
|
+
// Fallback: edit ~/.claude.json directly
|
|
77
|
+
log(' claude CLI not found — writing config directly...');
|
|
78
|
+
const claudeJson = path.join(os.homedir(), '.claude.json');
|
|
79
|
+
try {
|
|
80
|
+
let config = {};
|
|
81
|
+
if (fs.existsSync(claudeJson)) {
|
|
82
|
+
config = JSON.parse(fs.readFileSync(claudeJson, 'utf-8'));
|
|
83
|
+
}
|
|
84
|
+
if (!config.mcpServers)
|
|
85
|
+
config.mcpServers = {};
|
|
86
|
+
// Add openwriter as first entry (Claude Code loads sequentially)
|
|
87
|
+
const existing = config.mcpServers;
|
|
88
|
+
config.mcpServers = {
|
|
89
|
+
openwriter: { command: 'openwriter', args: ['--no-open'] },
|
|
90
|
+
...existing,
|
|
91
|
+
};
|
|
92
|
+
fs.writeFileSync(claudeJson, JSON.stringify(config, null, 2), 'utf-8');
|
|
93
|
+
log(` ✓ MCP server added to ${claudeJson}`);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
log(` ✗ Could not configure MCP server. Add manually:`);
|
|
98
|
+
log(' claude mcp add -s user openwriter -- openwriter --no-open');
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
7
102
|
export function installSkill() {
|
|
103
|
+
// Step 1: Copy SKILL.md
|
|
104
|
+
log('① Installing OpenWriter skill...');
|
|
8
105
|
const source = path.join(__dirname, '../../skill/SKILL.md');
|
|
9
106
|
const targetDir = path.join(os.homedir(), '.claude', 'skills', 'openwriter');
|
|
10
107
|
const target = path.join(targetDir, 'SKILL.md');
|
|
11
108
|
if (!fs.existsSync(source)) {
|
|
12
|
-
|
|
109
|
+
log(` Error: SKILL.md not found at ${source}`);
|
|
13
110
|
process.exit(1);
|
|
14
111
|
}
|
|
15
112
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
16
113
|
fs.copyFileSync(source, target);
|
|
17
|
-
|
|
114
|
+
log(` ✓ Skill installed to ${target}`);
|
|
115
|
+
// Step 2: Global install (skip if already installed)
|
|
116
|
+
const alreadyInstalled = isGloballyInstalled();
|
|
117
|
+
if (alreadyInstalled) {
|
|
118
|
+
log('\n② openwriter already installed globally — skipping');
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
if (!installGlobally()) {
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Step 3: Configure MCP server (skip if already configured)
|
|
126
|
+
if (isMcpConfigured()) {
|
|
127
|
+
log('\n③ MCP server already configured — skipping');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
configureMcp();
|
|
131
|
+
}
|
|
132
|
+
// Done
|
|
133
|
+
log('\n✓ OpenWriter is ready!');
|
|
134
|
+
log(' Restart Claude Code, then type /openwriter to start writing.\n');
|
|
18
135
|
process.exit(0);
|
|
19
136
|
}
|