@gpdoc/cli 1.2.2 → 1.4.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.
- package/README.md +47 -5
- package/bin/gpdoc.js +235 -20
- package/lib/auth.js +6 -4
- package/lib/editor-preview.js +186 -0
- package/lib/remote.js +79 -6
- package/package.json +4 -1
- package/web/editor.html +219 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
const {
|
|
6
|
+
readDocument,
|
|
7
|
+
serializeManagedMarkdown,
|
|
8
|
+
validateDocument,
|
|
9
|
+
} = await import('@gpdoc/filekit').catch(() => import('../../gpdoc-filekit/src/index.js'));
|
|
10
|
+
|
|
11
|
+
export class EditorPreviewError extends Error {
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BODY_LIMIT = 2 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
function revisionFor(value) {
|
|
21
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function snapshotFor(filePath, source) {
|
|
25
|
+
const document = readDocument(source, filePath);
|
|
26
|
+
if (!['markdown', 'gpdoc-markdown'].includes(document.format) || document.filetype !== 'document') {
|
|
27
|
+
throw new EditorPreviewError('UNSUPPORTED_EDITOR', 'GPEditor preview supports Markdown and GPDoc document files.');
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
document,
|
|
31
|
+
editor: {
|
|
32
|
+
filePath,
|
|
33
|
+
fileName: path.basename(filePath),
|
|
34
|
+
format: document.format,
|
|
35
|
+
managed: document.managed,
|
|
36
|
+
filetype: document.filetype,
|
|
37
|
+
content: document.body,
|
|
38
|
+
revision: revisionFor(source),
|
|
39
|
+
comments: document.metadata?.comments ?? null,
|
|
40
|
+
suggestions: document.metadata?.suggestions ?? null,
|
|
41
|
+
metadata: document.metadata ?? null,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readSnapshot(filePath) {
|
|
47
|
+
return snapshotFor(filePath, await readFile(filePath, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function atomicWrite(target, content) {
|
|
51
|
+
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
|
|
52
|
+
try {
|
|
53
|
+
await writeFile(temporary, content, 'utf8');
|
|
54
|
+
await rename(temporary, target);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await unlink(temporary).catch(() => {});
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sendJson(response, status, value) {
|
|
62
|
+
const body = JSON.stringify(value);
|
|
63
|
+
response.writeHead(status, {
|
|
64
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
65
|
+
'Content-Length': Buffer.byteLength(body),
|
|
66
|
+
'Cache-Control': 'no-store',
|
|
67
|
+
'X-Content-Type-Options': 'nosniff',
|
|
68
|
+
});
|
|
69
|
+
response.end(body);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readJson(request) {
|
|
73
|
+
const declared = Number(request.headers['content-length'] || 0);
|
|
74
|
+
if (declared > BODY_LIMIT) throw new EditorPreviewError('REQUEST_TOO_LARGE', 'The editor request is too large.');
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let size = 0;
|
|
77
|
+
for await (const chunk of request) {
|
|
78
|
+
size += chunk.byteLength;
|
|
79
|
+
if (size > BODY_LIMIT) throw new EditorPreviewError('REQUEST_TOO_LARGE', 'The editor request is too large.');
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
84
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid body');
|
|
85
|
+
return parsed;
|
|
86
|
+
} catch {
|
|
87
|
+
throw new EditorPreviewError('INVALID_INPUT', 'The editor request body must be a JSON object.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writePreviewError(response, error) {
|
|
92
|
+
const code = error?.code || 'EDITOR_PREVIEW_FAILED';
|
|
93
|
+
const status = code === 'REQUEST_TOO_LARGE' ? 413
|
|
94
|
+
: code === 'INVALID_INPUT' ? 400
|
|
95
|
+
: code === 'REVISION_CONFLICT' ? 409
|
|
96
|
+
: 500;
|
|
97
|
+
sendJson(response, status, { error: { code, message: error?.message || 'GPDoc editor preview failed.' } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function contentForSave(snapshot, content) {
|
|
101
|
+
if (snapshot.document.managed) return serializeManagedMarkdown(snapshot.document.metadata, content);
|
|
102
|
+
return content;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// @spec CLI-032
|
|
106
|
+
export async function startGPEditorPreview({ filePath, editorHtml }) {
|
|
107
|
+
if (!editorHtml) throw new EditorPreviewError('EDITOR_PREVIEW_UNAVAILABLE', 'GPEditor preview assets are unavailable. Reinstall @gpdoc/cli.');
|
|
108
|
+
const target = path.resolve(filePath);
|
|
109
|
+
const localEditorHtml = editorHtml.replace(/<\/head>/i, '<style>.share-action{display:none!important}</style></head>');
|
|
110
|
+
await readSnapshot(target);
|
|
111
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
112
|
+
let origin = '';
|
|
113
|
+
const server = createServer((request, response) => {
|
|
114
|
+
void (async () => {
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(request.url || '/', origin || 'http://127.0.0.1');
|
|
117
|
+
const editorPath = `/editor/${token}`;
|
|
118
|
+
const apiPath = `/api/editor/${token}`;
|
|
119
|
+
if (request.method === 'GET' && url.pathname === editorPath) {
|
|
120
|
+
response.writeHead(200, {
|
|
121
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
122
|
+
'Cache-Control': 'no-store',
|
|
123
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob: https:; connect-src 'self'; font-src data:; base-uri 'none'; frame-ancestors 'self'",
|
|
124
|
+
'Referrer-Policy': 'no-referrer',
|
|
125
|
+
'X-Content-Type-Options': 'nosniff',
|
|
126
|
+
});
|
|
127
|
+
response.end(localEditorHtml);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (url.pathname !== apiPath && url.pathname !== `${apiPath}/save`) {
|
|
131
|
+
sendJson(response, 404, { error: { code: 'ROUTE_NOT_FOUND', message: 'The editor route was not found.' } });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (request.headers.origin && request.headers.origin !== origin) {
|
|
135
|
+
sendJson(response, 403, { error: { code: 'ORIGIN_DENIED', message: 'The editor request origin was denied.' } });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (request.method === 'GET' && url.pathname === apiPath) {
|
|
139
|
+
const { editor } = await readSnapshot(target);
|
|
140
|
+
sendJson(response, 200, { editor });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (request.method !== 'POST' || url.pathname !== `${apiPath}/save`) {
|
|
144
|
+
sendJson(response, 405, { error: { code: 'METHOD_NOT_ALLOWED', message: 'The editor operation does not support this method.' } });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!String(request.headers['content-type'] || '').toLowerCase().startsWith('application/json')) {
|
|
148
|
+
sendJson(response, 415, { error: { code: 'CONTENT_TYPE_REQUIRED', message: 'The editor operation requires JSON.' } });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const input = await readJson(request);
|
|
152
|
+
if (typeof input.content !== 'string') throw new EditorPreviewError('INVALID_INPUT', 'Editor content must be text.');
|
|
153
|
+
const snapshot = await readSnapshot(target);
|
|
154
|
+
if (String(input.expectedRevision || '') !== snapshot.editor.revision) {
|
|
155
|
+
throw new EditorPreviewError('REVISION_CONFLICT', 'The local file changed. Reload the editor before saving.');
|
|
156
|
+
}
|
|
157
|
+
const nextSource = contentForSave(snapshot, input.content);
|
|
158
|
+
const prospective = snapshotFor(target, nextSource);
|
|
159
|
+
validateDocument(prospective.document);
|
|
160
|
+
await atomicWrite(target, nextSource);
|
|
161
|
+
const next = await readSnapshot(target);
|
|
162
|
+
sendJson(response, 200, { editor: next.editor, result: { path: target, revision: next.editor.revision } });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
writePreviewError(response, error);
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await new Promise((resolve, reject) => {
|
|
170
|
+
server.once('error', reject);
|
|
171
|
+
server.listen(0, '127.0.0.1', () => {
|
|
172
|
+
server.off('error', reject);
|
|
173
|
+
resolve();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
const address = server.address();
|
|
177
|
+
if (!address || typeof address === 'string') {
|
|
178
|
+
server.close();
|
|
179
|
+
throw new EditorPreviewError('EDITOR_PREVIEW_FAILED', 'The local GPEditor preview could not start.');
|
|
180
|
+
}
|
|
181
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
182
|
+
return {
|
|
183
|
+
url: `${origin}/editor/${token}`,
|
|
184
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
185
|
+
};
|
|
186
|
+
}
|
package/lib/remote.js
CHANGED
|
@@ -21,7 +21,11 @@ async function jsonRequest(fetchImpl, url, init, fallbackCode = 'REMOTE_REQUEST_
|
|
|
21
21
|
}
|
|
22
22
|
const payload = await response.json().catch(() => ({}));
|
|
23
23
|
if (!response.ok) {
|
|
24
|
-
const
|
|
24
|
+
const code = payload.code || fallbackCode;
|
|
25
|
+
const message = code === 'ACCOUNT_STATE_UNAVAILABLE'
|
|
26
|
+
? 'GPDoc account-state verification is temporarily unavailable. No provider request was sent. Retry after the GPDoc account service is available.'
|
|
27
|
+
: payload.error || `Remote provider request failed (${response.status}).`;
|
|
28
|
+
const error = new RemoteError(code, message);
|
|
25
29
|
error.status = response.status;
|
|
26
30
|
throw error;
|
|
27
31
|
}
|
|
@@ -69,33 +73,102 @@ function githubHeaders(token) {
|
|
|
69
73
|
// @spec CLI-019, CLI-020, CLI-021, CLI-022, CLI-023, CLI-024, CLI-025
|
|
70
74
|
export function createRemoteClient({ auth, env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
|
71
75
|
if (!auth) throw new Error('Remote client requires an auth manager.');
|
|
72
|
-
async function provider(functionName, body, method = 'POST') {
|
|
76
|
+
async function provider(functionName, body, method = 'POST', onProgress) {
|
|
73
77
|
const token = await auth.getAccessToken();
|
|
78
|
+
onProgress?.({ stage: 'request', functionName });
|
|
74
79
|
return jsonRequest(fetchImpl, `${apiBase(env)}/.netlify/functions/${functionName}`, {
|
|
75
80
|
method, headers: bearer(token), ...(method === 'GET' ? {} : { body: JSON.stringify(body || {}) }),
|
|
76
81
|
});
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
function connectionFunction(providerName, action) {
|
|
85
|
+
if (!['google', 'microsoft'].includes(providerName)) throw new RemoteError('USAGE', 'Provider must be google or microsoft.');
|
|
86
|
+
return `${providerName}-oauth-${action}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
return {
|
|
80
|
-
async googleSave({ filePath, title, mode = 'new', driveId, itemId, expectedRevision, filetype = 'document' }) {
|
|
90
|
+
async googleSave({ filePath, title, mode = 'new', driveId, itemId, expectedRevision, filetype = 'document', onProgress }) {
|
|
81
91
|
await auth.getAccessToken();
|
|
92
|
+
onProgress?.({ provider: 'google', stage: 'preparing', current: 1, total: 3, filePath });
|
|
82
93
|
const markdown = await readFile(filePath, 'utf8');
|
|
94
|
+
onProgress?.({ provider: 'google', stage: 'uploading', current: 2, total: 3, filePath, bytes: Buffer.byteLength(markdown) });
|
|
83
95
|
const payload = await provider('google-drive-source-save', {
|
|
84
96
|
title: title || titleFrom(filePath), markdown, mode, driveId, itemId, expectedRevision, filetype,
|
|
85
|
-
});
|
|
97
|
+
}, 'POST', () => {});
|
|
98
|
+
onProgress?.({ provider: 'google', stage: 'complete', current: 3, total: 3, filePath });
|
|
86
99
|
return { provider: 'google', mode, item: payload.item, warning: payload.warning || null };
|
|
87
100
|
},
|
|
88
101
|
|
|
89
|
-
async microsoftSave({ filePath, mode = 'new', driveId, itemId, filename }) {
|
|
102
|
+
async microsoftSave({ filePath, mode = 'new', driveId, itemId, filename, onProgress }) {
|
|
90
103
|
await auth.getAccessToken();
|
|
104
|
+
onProgress?.({ provider: 'microsoft', stage: 'preparing', current: 1, total: 3, filePath });
|
|
91
105
|
const bytes = await readFile(filePath);
|
|
92
106
|
if (bytes.byteLength > 4 * 1024 * 1024) throw new RemoteError('MICROSOFT_FILE_TOO_LARGE', 'Microsoft files must be 4 MiB or smaller.');
|
|
107
|
+
onProgress?.({ provider: 'microsoft', stage: 'uploading', current: 2, total: 3, filePath, bytes: bytes.byteLength });
|
|
93
108
|
const payload = await provider('microsoft-drive-export', {
|
|
94
109
|
mode, driveId, itemId, filename: filename || path.basename(filePath), mimeType: mimeType(filePath), bytes: bytes.toString('base64'),
|
|
95
|
-
});
|
|
110
|
+
}, 'POST', () => {});
|
|
111
|
+
onProgress?.({ provider: 'microsoft', stage: 'complete', current: 3, total: 3, filePath });
|
|
96
112
|
return { provider: 'microsoft', mode, item: payload.item || payload };
|
|
97
113
|
},
|
|
98
114
|
|
|
115
|
+
async providerStatus(providerName) {
|
|
116
|
+
const payload = await provider(connectionFunction(providerName, 'status'), undefined, 'GET');
|
|
117
|
+
return { provider: providerName, connected: Boolean(payload.connection?.connected), connection: payload.connection || null };
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async providerConnect(providerName) {
|
|
121
|
+
const payload = await provider(connectionFunction(providerName, 'start'), {
|
|
122
|
+
externalClient: true,
|
|
123
|
+
returnOrigin: apiBase(env),
|
|
124
|
+
});
|
|
125
|
+
const launchUrl = typeof payload.launchUrl === 'string' ? payload.launchUrl : payload.authUrl;
|
|
126
|
+
if (!launchUrl) throw new RemoteError('OAUTH_START_FAILED', `GPDoc could not start ${providerName} authorization.`);
|
|
127
|
+
return { provider: providerName, launchUrl, expiresAt: payload.expiresAt || null };
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async googleList({ driveId, parentId, cursor, shared = false }) {
|
|
131
|
+
if (driveId || parentId) {
|
|
132
|
+
if (!driveId || !parentId) throw new RemoteError('USAGE', 'Google listing requires both --drive-id and --parent-id.');
|
|
133
|
+
const payload = await provider('google-drive-browse', {
|
|
134
|
+
operation: 'children', driveId, parentId, rootKind: driveId === 'my-drive' ? 'my-drive' : 'shared-drive', continuation: cursor,
|
|
135
|
+
});
|
|
136
|
+
return { provider: 'google', items: payload.nodes || [], cursor: payload.cursor || null, incomplete: Boolean(payload.incomplete) };
|
|
137
|
+
}
|
|
138
|
+
const roots = await provider('google-drive-browse', undefined, 'GET');
|
|
139
|
+
const rootNodes = roots.nodes || [];
|
|
140
|
+
if (shared) return { provider: 'google', items: rootNodes.filter((node) => node?.providerData?.rootKind === 'shared-drive'), cursor: roots.cursor || null };
|
|
141
|
+
const myDrive = rootNodes.find((node) => node?.providerData?.rootKind === 'my-drive');
|
|
142
|
+
if (!myDrive?.providerData?.driveId || !myDrive?.providerData?.itemId) return { provider: 'google', items: [], cursor: roots.cursor || null };
|
|
143
|
+
const payload = await provider('google-drive-browse', {
|
|
144
|
+
operation: 'children', driveId: myDrive.providerData.driveId, parentId: myDrive.providerData.itemId, rootKind: 'my-drive', continuation: cursor,
|
|
145
|
+
});
|
|
146
|
+
return { provider: 'google', items: payload.nodes || [], cursor: payload.cursor || null, incomplete: Boolean(payload.incomplete) };
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async microsoftList({ driveId, itemId, cursor, query, shared = false }) {
|
|
150
|
+
const payload = query
|
|
151
|
+
? await provider('microsoft-drive-search', { query, continuation: cursor })
|
|
152
|
+
: await provider('microsoft-drive-browse', { driveId, itemId, continuation: cursor });
|
|
153
|
+
const entries = payload.nodes || payload.files || [];
|
|
154
|
+
const items = shared
|
|
155
|
+
? entries.filter((entry) => entry?.providerData?.remote === 'true' || entry?.remote === true)
|
|
156
|
+
: entries;
|
|
157
|
+
return { provider: 'microsoft', items, cursor: payload.cursor || payload.continuation || null };
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async sharedList() {
|
|
161
|
+
const token = await auth.getAccessToken();
|
|
162
|
+
const payload = await jsonRequest(fetchImpl, `${apiBase(env)}/api/shared-files`, {
|
|
163
|
+
method: 'GET', headers: bearer(token),
|
|
164
|
+
}, 'SHARED_FILES_REQUEST_FAILED');
|
|
165
|
+
return {
|
|
166
|
+
provider: 'gpdoc',
|
|
167
|
+
owned: payload.ownedFiles || [],
|
|
168
|
+
sharedWithMe: payload.collaboratingFiles || [],
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
|
|
99
172
|
async microsoftShare({ driveId, itemId, role, scope }) {
|
|
100
173
|
if (!driveId || !itemId) throw new RemoteError('USAGE', 'Microsoft sharing requires --drive-id and --item-id.');
|
|
101
174
|
if (!['view', 'edit'].includes(role)) throw new RemoteError('USAGE', 'Microsoft sharing requires --role view or edit.');
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gpdoc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "GPDoc command-line file conversion and validation tools",
|
|
6
6
|
"repository": "https://github.com/repetere/gpdoc.git",
|
|
7
7
|
"files": [
|
|
8
8
|
"bin",
|
|
9
9
|
"lib",
|
|
10
|
+
"web",
|
|
10
11
|
"README.md"
|
|
11
12
|
],
|
|
12
13
|
"publishConfig": {
|
|
@@ -19,6 +20,8 @@
|
|
|
19
20
|
"node": ">=20"
|
|
20
21
|
},
|
|
21
22
|
"scripts": {
|
|
23
|
+
"build:editor": "node scripts/build-editor.mjs",
|
|
24
|
+
"prepack": "npm run build:editor",
|
|
22
25
|
"test": "node --test test/**/*.test.js"
|
|
23
26
|
},
|
|
24
27
|
"dependencies": {
|