openwriter 0.26.0 → 0.27.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/client/assets/index-BJMpYpj1.css +1 -0
- package/dist/client/assets/index-DgUPw-v5.js +214 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +5 -9
- package/dist/plugins/authors-voice/dist/index.js +17 -130
- package/dist/plugins/authors-voice/package.json +1 -1
- package/dist/plugins/github/dist/blog-tools.d.ts +8 -0
- package/dist/plugins/github/dist/blog-tools.js +792 -0
- package/dist/plugins/github/dist/git-sync.d.ts +36 -0
- package/dist/plugins/github/dist/git-sync.js +276 -0
- package/dist/plugins/github/dist/helpers.d.ts +84 -0
- package/dist/plugins/github/dist/helpers.js +62 -0
- package/dist/plugins/github/dist/index.d.ts +12 -0
- package/dist/plugins/github/dist/index.js +102 -0
- package/dist/plugins/github/package.json +24 -0
- package/dist/server/autoplug-enroll.js +71 -0
- package/dist/server/documents.js +119 -2
- package/dist/server/index.js +49 -13
- package/dist/server/markdown-parse.js +74 -1
- package/dist/server/mcp.js +215 -78
- package/dist/server/pending-metadata.js +65 -0
- package/dist/server/pending-overlay.js +151 -2
- package/dist/server/plugin-manager.js +18 -3
- package/dist/server/state.js +126 -39
- package/dist/server/ws.js +85 -26
- package/package.json +1 -1
- package/skill/SKILL.md +49 -19
- package/dist/client/assets/index-AWIKUHJ_.css +0 -1
- package/dist/client/assets/index-DmHLFNTs.js +0 -212
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git sync module: all git/gh CLI interactions for GitHub docs backup.
|
|
3
|
+
* Lifted from packages/openwriter/server/git-sync.ts into the github plugin.
|
|
4
|
+
*
|
|
5
|
+
* Uses child_process.execFile with shell:true (required on Windows).
|
|
6
|
+
* Server-internal modules (state, helpers, ws) accessed via getServerModules().
|
|
7
|
+
*/
|
|
8
|
+
export type SyncState = 'unconfigured' | 'synced' | 'pending' | 'syncing' | 'error';
|
|
9
|
+
export interface SyncStatus {
|
|
10
|
+
state: SyncState;
|
|
11
|
+
lastSyncTime?: string;
|
|
12
|
+
pendingFiles?: number;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SyncCapabilities {
|
|
16
|
+
gitInstalled: boolean;
|
|
17
|
+
ghInstalled: boolean;
|
|
18
|
+
ghAuthenticated: boolean;
|
|
19
|
+
existingRepo: boolean;
|
|
20
|
+
remoteUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function isGitInstalled(): Promise<boolean>;
|
|
23
|
+
export declare function isGhInstalled(): Promise<boolean>;
|
|
24
|
+
export declare function isGhAuthenticated(): Promise<boolean>;
|
|
25
|
+
export declare function isGitRepo(): Promise<boolean>;
|
|
26
|
+
export interface PendingFile {
|
|
27
|
+
status: 'added' | 'modified' | 'deleted' | 'renamed';
|
|
28
|
+
file: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function getPendingFiles(): Promise<PendingFile[]>;
|
|
31
|
+
export declare function getSyncStatus(): Promise<SyncStatus>;
|
|
32
|
+
export declare function getCapabilities(): Promise<SyncCapabilities>;
|
|
33
|
+
export declare function setupWithGh(repoName: string, isPrivate: boolean): Promise<void>;
|
|
34
|
+
export declare function setupWithPat(pat: string, repoName: string, isPrivate: boolean): Promise<void>;
|
|
35
|
+
export declare function connectExisting(remoteUrl: string, pat?: string): Promise<void>;
|
|
36
|
+
export declare function pushSync(onStatus: (status: SyncStatus) => void): Promise<SyncStatus>;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git sync module: all git/gh CLI interactions for GitHub docs backup.
|
|
3
|
+
* Lifted from packages/openwriter/server/git-sync.ts into the github plugin.
|
|
4
|
+
*
|
|
5
|
+
* Uses child_process.execFile with shell:true (required on Windows).
|
|
6
|
+
* Server-internal modules (state, helpers, ws) accessed via getServerModules().
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'child_process';
|
|
9
|
+
import { existsSync, writeFileSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { getServerModules } from './helpers.js';
|
|
12
|
+
const GITIGNORE_CONTENT = `config.json\n.versions/\n`;
|
|
13
|
+
const NETWORK_TIMEOUT = 30000;
|
|
14
|
+
let currentSyncState = 'unconfigured';
|
|
15
|
+
let lastError;
|
|
16
|
+
function exec(cmd, args, cwd, timeout = 10000) {
|
|
17
|
+
const safeArgs = args.map(a => a.includes(' ') ? `"${a}"` : a);
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
execFile(cmd, safeArgs, { cwd, shell: true, timeout }, (err, stdout, stderr) => {
|
|
20
|
+
if (err)
|
|
21
|
+
reject(new Error(stderr?.trim() || err.message));
|
|
22
|
+
else
|
|
23
|
+
resolve(stdout.trim());
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async function dataDir() {
|
|
28
|
+
return (await getServerModules()).getDataDir();
|
|
29
|
+
}
|
|
30
|
+
export async function isGitInstalled() {
|
|
31
|
+
try {
|
|
32
|
+
await exec('git', ['--version'], await dataDir());
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function isGhInstalled() {
|
|
40
|
+
try {
|
|
41
|
+
await exec('gh', ['--version'], await dataDir());
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function isGhAuthenticated() {
|
|
49
|
+
try {
|
|
50
|
+
await exec('gh', ['auth', 'status'], await dataDir());
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function isGitRepo() {
|
|
58
|
+
return existsSync(join(await dataDir(), '.git'));
|
|
59
|
+
}
|
|
60
|
+
async function ensureGitignore() {
|
|
61
|
+
const gitignorePath = join(await dataDir(), '.gitignore');
|
|
62
|
+
if (!existsSync(gitignorePath)) {
|
|
63
|
+
writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function countPendingFiles() {
|
|
67
|
+
if (!(await isGitRepo()))
|
|
68
|
+
return 0;
|
|
69
|
+
try {
|
|
70
|
+
const status = await exec('git', ['status', '--porcelain'], await dataDir());
|
|
71
|
+
if (!status)
|
|
72
|
+
return 0;
|
|
73
|
+
return status.split('\n').filter(Boolean).length;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function getPendingFiles() {
|
|
80
|
+
if (!(await isGitRepo()))
|
|
81
|
+
return [];
|
|
82
|
+
try {
|
|
83
|
+
const output = await exec('git', ['status', '--porcelain'], await dataDir());
|
|
84
|
+
if (!output)
|
|
85
|
+
return [];
|
|
86
|
+
return output.split('\n').filter(Boolean).map(line => {
|
|
87
|
+
const code = line.substring(0, 2);
|
|
88
|
+
const file = line.substring(3);
|
|
89
|
+
let status = 'modified';
|
|
90
|
+
if (code.includes('?') || code.includes('A'))
|
|
91
|
+
status = 'added';
|
|
92
|
+
else if (code.includes('D'))
|
|
93
|
+
status = 'deleted';
|
|
94
|
+
else if (code.includes('R'))
|
|
95
|
+
status = 'renamed';
|
|
96
|
+
return { status, file };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function getSyncStatus() {
|
|
104
|
+
const srv = await getServerModules();
|
|
105
|
+
const config = srv.readConfig();
|
|
106
|
+
if (!config.gitConfigured || !(await isGitRepo())) {
|
|
107
|
+
return { state: 'unconfigured' };
|
|
108
|
+
}
|
|
109
|
+
if (currentSyncState === 'syncing') {
|
|
110
|
+
return { state: 'syncing' };
|
|
111
|
+
}
|
|
112
|
+
if (currentSyncState === 'error' && lastError) {
|
|
113
|
+
return { state: 'error', error: lastError, lastSyncTime: config.lastSyncTime };
|
|
114
|
+
}
|
|
115
|
+
const pending = await countPendingFiles();
|
|
116
|
+
return {
|
|
117
|
+
state: pending > 0 ? 'pending' : 'synced',
|
|
118
|
+
pendingFiles: pending,
|
|
119
|
+
lastSyncTime: config.lastSyncTime,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export async function getCapabilities() {
|
|
123
|
+
const [git, gh] = await Promise.all([isGitInstalled(), isGhInstalled()]);
|
|
124
|
+
let ghAuth = false;
|
|
125
|
+
if (gh)
|
|
126
|
+
ghAuth = await isGhAuthenticated();
|
|
127
|
+
let remoteUrl;
|
|
128
|
+
if (await isGitRepo()) {
|
|
129
|
+
try {
|
|
130
|
+
remoteUrl = await exec('git', ['remote', 'get-url', 'origin'], await dataDir());
|
|
131
|
+
}
|
|
132
|
+
catch { /* no remote */ }
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
gitInstalled: git,
|
|
136
|
+
ghInstalled: gh,
|
|
137
|
+
ghAuthenticated: ghAuth,
|
|
138
|
+
existingRepo: await isGitRepo(),
|
|
139
|
+
remoteUrl,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function initRepo() {
|
|
143
|
+
const dir = await dataDir();
|
|
144
|
+
if (!(await isGitRepo())) {
|
|
145
|
+
await exec('git', ['init'], dir);
|
|
146
|
+
}
|
|
147
|
+
await ensureGitignore();
|
|
148
|
+
try {
|
|
149
|
+
await exec('git', ['config', 'user.name'], dir);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
await exec('git', ['config', 'user.name', 'OpenWriter'], dir);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await exec('git', ['config', 'user.email'], dir);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
await exec('git', ['config', 'user.email', 'openwriter@local'], dir);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function initialCommit() {
|
|
162
|
+
const dir = await dataDir();
|
|
163
|
+
await exec('git', ['add', '-A'], dir);
|
|
164
|
+
const status = await exec('git', ['status', '--porcelain'], dir);
|
|
165
|
+
if (!status)
|
|
166
|
+
return;
|
|
167
|
+
await exec('git', ['commit', '-m', 'Initial sync from OpenWriter'], dir);
|
|
168
|
+
await exec('git', ['branch', '-M', 'main'], dir);
|
|
169
|
+
}
|
|
170
|
+
export async function setupWithGh(repoName, isPrivate) {
|
|
171
|
+
const srv = await getServerModules();
|
|
172
|
+
const dir = await dataDir();
|
|
173
|
+
await initRepo();
|
|
174
|
+
await initialCommit();
|
|
175
|
+
const visibility = isPrivate ? '--private' : '--public';
|
|
176
|
+
await exec('gh', ['repo', 'create', repoName, visibility, '--source=.', '--remote=origin'], dir, NETWORK_TIMEOUT);
|
|
177
|
+
await exec('git', ['push', '-u', 'origin', 'main'], dir, NETWORK_TIMEOUT);
|
|
178
|
+
srv.saveConfig({
|
|
179
|
+
gitConfigured: true,
|
|
180
|
+
repoName,
|
|
181
|
+
lastSyncTime: new Date().toISOString(),
|
|
182
|
+
});
|
|
183
|
+
currentSyncState = 'synced';
|
|
184
|
+
}
|
|
185
|
+
export async function setupWithPat(pat, repoName, isPrivate) {
|
|
186
|
+
const srv = await getServerModules();
|
|
187
|
+
const dir = await dataDir();
|
|
188
|
+
const res = await fetch('https://api.github.com/user/repos', {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: {
|
|
191
|
+
Authorization: `Bearer ${pat}`,
|
|
192
|
+
'Content-Type': 'application/json',
|
|
193
|
+
Accept: 'application/vnd.github+json',
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify({ name: repoName, private: isPrivate, auto_init: false }),
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const body = await res.json().catch(() => ({}));
|
|
199
|
+
throw new Error(body.message || `GitHub API error: ${res.status}`);
|
|
200
|
+
}
|
|
201
|
+
const repo = await res.json();
|
|
202
|
+
const remoteUrl = `https://${pat}@github.com/${repo.full_name}.git`;
|
|
203
|
+
await initRepo();
|
|
204
|
+
await initialCommit();
|
|
205
|
+
try {
|
|
206
|
+
await exec('git', ['remote', 'remove', 'origin'], dir);
|
|
207
|
+
}
|
|
208
|
+
catch { /* no remote */ }
|
|
209
|
+
await exec('git', ['remote', 'add', 'origin', remoteUrl], dir);
|
|
210
|
+
await exec('git', ['push', '-u', 'origin', 'main'], dir, NETWORK_TIMEOUT);
|
|
211
|
+
srv.saveConfig({
|
|
212
|
+
gitConfigured: true,
|
|
213
|
+
gitPat: pat,
|
|
214
|
+
repoName,
|
|
215
|
+
gitRemote: repo.html_url,
|
|
216
|
+
lastSyncTime: new Date().toISOString(),
|
|
217
|
+
});
|
|
218
|
+
currentSyncState = 'synced';
|
|
219
|
+
}
|
|
220
|
+
export async function connectExisting(remoteUrl, pat) {
|
|
221
|
+
const srv = await getServerModules();
|
|
222
|
+
const dir = await dataDir();
|
|
223
|
+
await initRepo();
|
|
224
|
+
await initialCommit();
|
|
225
|
+
let finalUrl = remoteUrl;
|
|
226
|
+
if (pat && remoteUrl.startsWith('https://')) {
|
|
227
|
+
finalUrl = remoteUrl.replace('https://', `https://${pat}@`);
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await exec('git', ['remote', 'remove', 'origin'], dir);
|
|
231
|
+
}
|
|
232
|
+
catch { /* no remote */ }
|
|
233
|
+
await exec('git', ['remote', 'add', 'origin', finalUrl], dir);
|
|
234
|
+
await exec('git', ['push', '-u', 'origin', 'main'], dir, NETWORK_TIMEOUT);
|
|
235
|
+
srv.saveConfig({
|
|
236
|
+
gitConfigured: true,
|
|
237
|
+
gitPat: pat,
|
|
238
|
+
gitRemote: remoteUrl,
|
|
239
|
+
lastSyncTime: new Date().toISOString(),
|
|
240
|
+
});
|
|
241
|
+
currentSyncState = 'synced';
|
|
242
|
+
}
|
|
243
|
+
export async function pushSync(onStatus) {
|
|
244
|
+
const srv = await getServerModules();
|
|
245
|
+
const dir = await dataDir();
|
|
246
|
+
currentSyncState = 'syncing';
|
|
247
|
+
lastError = undefined;
|
|
248
|
+
onStatus({ state: 'syncing' });
|
|
249
|
+
try {
|
|
250
|
+
srv.cancelDebouncedSave();
|
|
251
|
+
srv.save();
|
|
252
|
+
await ensureGitignore();
|
|
253
|
+
await exec('git', ['add', '-A'], dir);
|
|
254
|
+
const status = await exec('git', ['status', '--porcelain'], dir);
|
|
255
|
+
if (status) {
|
|
256
|
+
const timestamp = new Date().toLocaleString('en-US', {
|
|
257
|
+
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
|
258
|
+
});
|
|
259
|
+
await exec('git', ['commit', '-m', `Sync: ${timestamp}`], dir);
|
|
260
|
+
}
|
|
261
|
+
await exec('git', ['push'], dir, NETWORK_TIMEOUT);
|
|
262
|
+
const now = new Date().toISOString();
|
|
263
|
+
srv.saveConfig({ lastSyncTime: now });
|
|
264
|
+
currentSyncState = 'synced';
|
|
265
|
+
const result = { state: 'synced', lastSyncTime: now, pendingFiles: 0 };
|
|
266
|
+
onStatus(result);
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
currentSyncState = 'error';
|
|
271
|
+
lastError = err.message;
|
|
272
|
+
const result = { state: 'error', error: err.message };
|
|
273
|
+
onStatus(result);
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local helpers for the github plugin — server-module bridge.
|
|
3
|
+
* Mirrors the lazy-import pattern from plugins/publish/src/helpers.ts.
|
|
4
|
+
*/
|
|
5
|
+
export interface ServerModules {
|
|
6
|
+
getDataDir: () => string;
|
|
7
|
+
readConfig: () => any;
|
|
8
|
+
saveConfig: (patch: Record<string, any>) => void;
|
|
9
|
+
save: () => void;
|
|
10
|
+
cancelDebouncedSave: () => void;
|
|
11
|
+
getDocument: () => any;
|
|
12
|
+
getTitle: () => string;
|
|
13
|
+
getMetadata: () => Record<string, any>;
|
|
14
|
+
getDocId: () => string;
|
|
15
|
+
setMetadata: (updates: Record<string, any>) => void;
|
|
16
|
+
bumpDocVersion: () => number;
|
|
17
|
+
broadcastSyncStatus: (status: any) => void;
|
|
18
|
+
tiptapToMarkdown: (doc: any, title: string, metadata?: Record<string, any>) => string;
|
|
19
|
+
}
|
|
20
|
+
export declare function getServerModules(): Promise<ServerModules>;
|
|
21
|
+
export interface PluginConfigField {
|
|
22
|
+
type: 'string' | 'number' | 'boolean';
|
|
23
|
+
required?: boolean;
|
|
24
|
+
env?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface PluginRouteContext {
|
|
28
|
+
app: import('express').Router;
|
|
29
|
+
config: Record<string, string>;
|
|
30
|
+
dataDir: string;
|
|
31
|
+
}
|
|
32
|
+
export interface PluginMcpTool {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
inputSchema: Record<string, unknown>;
|
|
36
|
+
handler: (params: Record<string, unknown>) => Promise<unknown>;
|
|
37
|
+
}
|
|
38
|
+
export interface OpenWriterPlugin {
|
|
39
|
+
name: string;
|
|
40
|
+
version: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
category?: 'writing' | 'social-media' | 'image-generation' | 'publishing' | 'productivity' | 'analytics';
|
|
43
|
+
configSchema?: Record<string, PluginConfigField>;
|
|
44
|
+
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
45
|
+
mcpTools?(config: Record<string, string>): PluginMcpTool[];
|
|
46
|
+
}
|
|
47
|
+
export interface BlogSite {
|
|
48
|
+
id: string;
|
|
49
|
+
label: string;
|
|
50
|
+
owner: string;
|
|
51
|
+
repo: string;
|
|
52
|
+
branch: string;
|
|
53
|
+
content_dir: string;
|
|
54
|
+
image_dir: string;
|
|
55
|
+
image_public_prefix: string;
|
|
56
|
+
framework: 'astro' | 'next' | 'jekyll' | 'hugo' | 'unknown';
|
|
57
|
+
/** Constant frontmatter fields applied to every post (e.g. layout, author, prerender). */
|
|
58
|
+
frontmatter_defaults?: Record<string, any>;
|
|
59
|
+
/**
|
|
60
|
+
* Map openwriter blogContext key → site frontmatter key.
|
|
61
|
+
* e.g. { date: "publishedDate" } for sites that use a non-standard date field.
|
|
62
|
+
*/
|
|
63
|
+
frontmatter_field_map?: Record<string, string>;
|
|
64
|
+
/**
|
|
65
|
+
* Schema of frontmatter fields the site's posts use (from inspection).
|
|
66
|
+
* Surfaced in the panel so users can see what the site expects.
|
|
67
|
+
* Not used as a filter — only the defaults + blogContext determine output.
|
|
68
|
+
*/
|
|
69
|
+
frontmatter_schema?: string[];
|
|
70
|
+
/**
|
|
71
|
+
* Public base URL of the site (e.g. "https://example.com"). Used by
|
|
72
|
+
* post_to_blog to construct the live URL stamped on `blogContext.lastPublish.publishedUrl`
|
|
73
|
+
* so the file-tree right-click menu can surface "View Post" after a publish.
|
|
74
|
+
* inspect_blog_repo proposes this from `CNAME` / `public/CNAME` when present.
|
|
75
|
+
*/
|
|
76
|
+
site_url?: string;
|
|
77
|
+
/**
|
|
78
|
+
* URL path pattern for a blog post, with `{slug}` as the placeholder.
|
|
79
|
+
* Default: `/blog/{slug}/`. Used together with `site_url` to build the live URL.
|
|
80
|
+
*/
|
|
81
|
+
blog_url_pattern?: string;
|
|
82
|
+
}
|
|
83
|
+
export declare function listBlogSites(): Promise<BlogSite[]>;
|
|
84
|
+
export declare function writeBlogSites(sites: BlogSite[]): Promise<void>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local helpers for the github plugin — server-module bridge.
|
|
3
|
+
* Mirrors the lazy-import pattern from plugins/publish/src/helpers.ts.
|
|
4
|
+
*/
|
|
5
|
+
// npm package: dist/plugins/github/dist/helpers.js → ../../../server/
|
|
6
|
+
// Monorepo dev: plugins/github/dist/helpers.js → ../../../packages/openwriter/dist/server/
|
|
7
|
+
const npmBase = new URL('../../../server/', import.meta.url).href;
|
|
8
|
+
const monoBase = new URL('../../../packages/openwriter/dist/server/', import.meta.url).href;
|
|
9
|
+
let _cached = null;
|
|
10
|
+
async function tryImport(base) {
|
|
11
|
+
const [helpers, state, ws, markdown] = await Promise.all([
|
|
12
|
+
import(base + 'helpers.js'),
|
|
13
|
+
import(base + 'state.js'),
|
|
14
|
+
import(base + 'ws.js'),
|
|
15
|
+
import(base + 'markdown.js'),
|
|
16
|
+
]);
|
|
17
|
+
return { helpers, state, ws, markdown };
|
|
18
|
+
}
|
|
19
|
+
export async function getServerModules() {
|
|
20
|
+
if (_cached)
|
|
21
|
+
return _cached;
|
|
22
|
+
let helpers, state, ws, markdown;
|
|
23
|
+
try {
|
|
24
|
+
({ helpers, state, ws, markdown } = await tryImport(npmBase));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
({ helpers, state, ws, markdown } = await tryImport(monoBase));
|
|
28
|
+
}
|
|
29
|
+
_cached = {
|
|
30
|
+
getDataDir: helpers.getDataDir,
|
|
31
|
+
readConfig: helpers.readConfig,
|
|
32
|
+
saveConfig: helpers.saveConfig,
|
|
33
|
+
save: state.save,
|
|
34
|
+
cancelDebouncedSave: state.cancelDebouncedSave,
|
|
35
|
+
getDocument: state.getDocument,
|
|
36
|
+
getTitle: state.getTitle,
|
|
37
|
+
getMetadata: state.getMetadata,
|
|
38
|
+
getDocId: state.getDocId,
|
|
39
|
+
setMetadata: state.setMetadata,
|
|
40
|
+
bumpDocVersion: state.bumpDocVersion,
|
|
41
|
+
broadcastSyncStatus: ws.broadcastSyncStatus,
|
|
42
|
+
tiptapToMarkdown: markdown.tiptapToMarkdown,
|
|
43
|
+
};
|
|
44
|
+
return _cached;
|
|
45
|
+
}
|
|
46
|
+
const PLUGIN_NAME = '@openwriter/plugin-github';
|
|
47
|
+
export async function listBlogSites() {
|
|
48
|
+
const srv = await getServerModules();
|
|
49
|
+
const cfg = srv.readConfig() || {};
|
|
50
|
+
const slot = cfg.plugins?.[PLUGIN_NAME];
|
|
51
|
+
return slot?.blogSites || [];
|
|
52
|
+
}
|
|
53
|
+
// adr: adr/plugin-slot-nested-data.md
|
|
54
|
+
export async function writeBlogSites(sites) {
|
|
55
|
+
const srv = await getServerModules();
|
|
56
|
+
const cfg = srv.readConfig() || {};
|
|
57
|
+
const plugins = { ...(cfg.plugins || {}) };
|
|
58
|
+
const slot = { ...(plugins[PLUGIN_NAME] || { enabled: true, config: {} }) };
|
|
59
|
+
slot.blogSites = sites;
|
|
60
|
+
plugins[PLUGIN_NAME] = slot;
|
|
61
|
+
srv.saveConfig({ plugins });
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Plugin for OpenWriter.
|
|
3
|
+
* Provides two capabilities:
|
|
4
|
+
* 1. Docs backup sync — lifted from core. Auth: existing `gh auth`.
|
|
5
|
+
* 2. Blog sites (Phase 3+) — list of blog repos, post via local git ops.
|
|
6
|
+
*
|
|
7
|
+
* This phase ships capability 1 only. Routes match the previous core mount
|
|
8
|
+
* points exactly so SyncButton + SyncSetupModal continue to work unchanged.
|
|
9
|
+
*/
|
|
10
|
+
import type { OpenWriterPlugin } from './helpers.js';
|
|
11
|
+
declare const plugin: OpenWriterPlugin;
|
|
12
|
+
export default plugin;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Plugin for OpenWriter.
|
|
3
|
+
* Provides two capabilities:
|
|
4
|
+
* 1. Docs backup sync — lifted from core. Auth: existing `gh auth`.
|
|
5
|
+
* 2. Blog sites (Phase 3+) — list of blog repos, post via local git ops.
|
|
6
|
+
*
|
|
7
|
+
* This phase ships capability 1 only. Routes match the previous core mount
|
|
8
|
+
* points exactly so SyncButton + SyncSetupModal continue to work unchanged.
|
|
9
|
+
*/
|
|
10
|
+
import { getServerModules } from './helpers.js';
|
|
11
|
+
import { getSyncStatus, getCapabilities, getPendingFiles, setupWithGh, setupWithPat, connectExisting, pushSync, } from './git-sync.js';
|
|
12
|
+
import { blogTools } from './blog-tools.js';
|
|
13
|
+
const plugin = {
|
|
14
|
+
name: '@openwriter/plugin-github',
|
|
15
|
+
version: '0.1.0',
|
|
16
|
+
description: 'GitHub Plugin: Sync files and publish blogs.',
|
|
17
|
+
category: 'productivity',
|
|
18
|
+
// No user-input credentials — auth piggybacks on the user's existing
|
|
19
|
+
// `gh auth` (verified in getCapabilities). Blog-site list is stored
|
|
20
|
+
// outside the configSchema as structured data (Phase 3+).
|
|
21
|
+
configSchema: {},
|
|
22
|
+
mcpTools() {
|
|
23
|
+
return blogTools();
|
|
24
|
+
},
|
|
25
|
+
registerRoutes(ctx) {
|
|
26
|
+
const broadcast = async (status) => {
|
|
27
|
+
try {
|
|
28
|
+
const srv = await getServerModules();
|
|
29
|
+
srv.broadcastSyncStatus(status);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error('[GitHub Plugin] broadcast failed:', err.message);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
ctx.app.get('/api/sync/status', async (_req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
res.json(await getSyncStatus());
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
res.status(500).json({ state: 'error', error: err.message });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
ctx.app.get('/api/sync/capabilities', async (_req, res) => {
|
|
44
|
+
try {
|
|
45
|
+
res.json(await getCapabilities());
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
res.status(500).json({ error: err.message });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
ctx.app.get('/api/sync/pending', async (_req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
res.json(await getPendingFiles());
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
res.status(500).json({ error: err.message });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
ctx.app.post('/api/sync/setup', async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const { method, repoName, remoteUrl, pat, isPrivate } = req.body;
|
|
62
|
+
if (method === 'gh') {
|
|
63
|
+
await setupWithGh(repoName || 'openwriter-docs', isPrivate !== false);
|
|
64
|
+
}
|
|
65
|
+
else if (method === 'pat') {
|
|
66
|
+
if (!pat) {
|
|
67
|
+
res.status(400).json({ error: 'PAT is required' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await setupWithPat(pat, repoName || 'openwriter-docs', isPrivate !== false);
|
|
71
|
+
}
|
|
72
|
+
else if (method === 'connect') {
|
|
73
|
+
if (!remoteUrl) {
|
|
74
|
+
res.status(400).json({ error: 'Remote URL is required' });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await connectExisting(remoteUrl, pat);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
res.status(400).json({ error: 'Invalid method. Use: gh, pat, or connect' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const status = await getSyncStatus();
|
|
84
|
+
await broadcast(status);
|
|
85
|
+
res.json({ success: true, status });
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
res.status(500).json({ error: err.message });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
ctx.app.post('/api/sync/push', async (_req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const result = await pushSync((s) => { void broadcast(s); });
|
|
94
|
+
res.json(result);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
res.status(500).json({ state: 'error', error: err.message });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
export default plugin;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwriter/plugin-github",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GitHub Plugin: Sync files and publish blogs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/express": "^5.0.0",
|
|
14
|
+
"typescript": "^5.6.0"
|
|
15
|
+
},
|
|
16
|
+
"openwriter": {
|
|
17
|
+
"displayName": "GitHub",
|
|
18
|
+
"category": "productivity"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/",
|
|
22
|
+
"package.json"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual-post → autoplug enrollment bridge.
|
|
3
|
+
*
|
|
4
|
+
* API/scheduler posts enter the platform's autoplug tracking at post time
|
|
5
|
+
* (the platform calls autoTrackTweet on /connections/:id/post, post-thread,
|
|
6
|
+
* the scheduler cron, and on POST /publications). Manual posts only ever
|
|
7
|
+
* wrote local `tweetContext.lastPost` frontmatter and never told the platform,
|
|
8
|
+
* so they never enrolled — and quote tweets, which the X API cannot post and
|
|
9
|
+
* therefore ALWAYS go through manual mark-sent, could never be autoplug-tracked.
|
|
10
|
+
*
|
|
11
|
+
* This reconciles the two: when a tweet/article doc is marked posted with a
|
|
12
|
+
* tweet URL, we record a publication on the platform. The platform's
|
|
13
|
+
* POST /publications handler enrolls X publications into autoplug tracking,
|
|
14
|
+
* so engagement-threshold autoplugs now fire on manually-posted tweets the
|
|
15
|
+
* same way they do for API/scheduler posts.
|
|
16
|
+
*/
|
|
17
|
+
import { isAuthenticated, platformFetch } from './connections.js';
|
|
18
|
+
const TWEET_ID_RE = /(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/i;
|
|
19
|
+
/** Pull the numeric tweet id out of an x.com / twitter.com status URL. */
|
|
20
|
+
export function extractTweetId(url) {
|
|
21
|
+
if (!url)
|
|
22
|
+
return null;
|
|
23
|
+
const m = TWEET_ID_RE.exec(url);
|
|
24
|
+
return m ? m[1] : null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Enroll a manually-posted tweet into platform autoplug tracking.
|
|
28
|
+
*
|
|
29
|
+
* Best-effort and idempotent: skips when the tweet is already recorded as a
|
|
30
|
+
* publication for this doc, so re-marking, unmark/remark, and autosave churn
|
|
31
|
+
* never duplicate the enrollment. Never throws — enrollment must never block
|
|
32
|
+
* or break the mark-sent metadata write.
|
|
33
|
+
*/
|
|
34
|
+
export async function enrollManualPostForAutoplug(docId, tweetUrl, text) {
|
|
35
|
+
if (!isAuthenticated())
|
|
36
|
+
return;
|
|
37
|
+
const tweetId = extractTweetId(tweetUrl);
|
|
38
|
+
if (!tweetId)
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
// Idempotency: the platform's publications table has no unique constraint,
|
|
42
|
+
// so dedupe here before recording another row + re-enrolling.
|
|
43
|
+
const existingRes = await platformFetch(`/publications?documentId=${encodeURIComponent(docId)}`);
|
|
44
|
+
if (existingRes.ok) {
|
|
45
|
+
const data = await existingRes.json();
|
|
46
|
+
const already = (data.publications || []).some((p) => p.platform === 'x' && p.external_id === tweetId);
|
|
47
|
+
if (already)
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Recording the publication triggers autoTrackTweet platform-side, which
|
|
51
|
+
// enrolls the tweet for autoplug rule evaluation (guarded there by the
|
|
52
|
+
// profile having ≥1 enabled goal + an active X connection).
|
|
53
|
+
const res = await platformFetch('/publications', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
document_id: docId,
|
|
57
|
+
platform: 'x',
|
|
58
|
+
external_id: tweetId,
|
|
59
|
+
url: tweetUrl,
|
|
60
|
+
meta: { text: text || '', last_tweet_id: tweetId },
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
if (res.ok) {
|
|
64
|
+
console.log(`[Autoplug] Enrolled manual post ${tweetId} (doc ${docId}) into tracking`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Best-effort: platform may be unreachable / unauthenticated. Mark-sent
|
|
69
|
+
// already succeeded locally; tracking simply won't enroll this time.
|
|
70
|
+
}
|
|
71
|
+
}
|