@gpdoc/cli 1.3.0 → 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 +40 -5
- package/bin/gpdoc.js +125 -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
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# GPDoc CLI
|
|
2
2
|
|
|
3
|
-
The GPDoc CLI creates, inspects, validates, converts, and
|
|
3
|
+
The GPDoc CLI creates, inspects, validates, converts, edits, and shares document files from a terminal. It uses the same GPDoc file conversion logic as the GPDoc apps and opens a local GPEditor session for editing by default.
|
|
4
4
|
|
|
5
5
|
From this repository, run commands through `npm run cli --`:
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
npm run cli -- new docs/release-plan.gpdoc.md --title "Release plan"
|
|
9
|
+
npm run cli -- --version
|
|
9
10
|
npm run cli -- convert notes.html --to gpdoc --output docs/notes.gpdoc.md
|
|
10
11
|
npm run cli -- convert https://example.com/guide --to markdown --output guide.md
|
|
11
12
|
npm run cli -- validate docs/notes.gpdoc.md --json
|
|
@@ -19,13 +20,45 @@ Supported input formats are Markdown, managed GPDoc Markdown, HTML, plain text,
|
|
|
19
20
|
|
|
20
21
|
Conversions write to standard output or an explicit output path. Existing files are never overwritten unless `--in-place` is supplied for the source file.
|
|
21
22
|
|
|
23
|
+
## Editing
|
|
24
|
+
|
|
25
|
+
`gpdoc edit FILE` starts a short-lived server on `127.0.0.1`, opens the selected file in GPEditor, and saves back to that file. The server uses a random URL, exposes no other workspace files, and closes when you press `Control-C`. Keep the command running while you edit.
|
|
26
|
+
|
|
27
|
+
Use a local terminal editor instead when needed:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
gpdoc edit docs/release-plan.gpdoc.md --editor local
|
|
31
|
+
VISUAL=code gpdoc edit docs/release-plan.gpdoc.md --editor local
|
|
32
|
+
GPDOC_EDITOR=local gpdoc edit docs/release-plan.gpdoc.md
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The `local` mode uses `$VISUAL`, then `$EDITOR`. Web editing is the default and can be selected explicitly with `--editor web`.
|
|
36
|
+
|
|
22
37
|
## Account and remote providers
|
|
23
38
|
|
|
24
|
-
Use `gpdoc login` to begin GPDoc's device authorization flow. It attempts to open your browser, waits for authorization, and writes the local CLI session before returning. If it cannot launch a browser, use the printed URL and code manually. Use `gpdoc login --no-browser` in a headless session. If you interrupt the command after completing browser authorization, run `gpdoc login complete` before the displayed expiry to finish saving the local session. `gpdoc whoami --json`
|
|
39
|
+
Use `gpdoc login` to begin GPDoc's device authorization flow. It attempts to open your browser, waits for authorization, and writes the local CLI session before returning. If it cannot launch a browser, use the printed URL and code manually. Use `gpdoc login --no-browser` in a headless session. If you interrupt the command after completing browser authorization, run `gpdoc login complete` before the displayed expiry to finish saving the local session. `gpdoc whoami` displays the available identity claims without credentials. `gpdoc whoami --json` returns the same data as structured JSON. `gpdoc logout` clears only the local CLI credential file.
|
|
40
|
+
|
|
41
|
+
For CI, set `GPDOC_ACCESS_TOKEN` instead of signing in interactively. The CLI does not persist that environment value. Interactive credentials are stored outside the current workspace with user-only permissions. The device flow requests `offline_access`; GPDoc refreshes the short-lived access token automatically while the refresh token remains valid. The access-token expiry shown by `whoami` is not the expected time until the next interactive login. Refresh-token lifetime and revocation remain controlled by the GPDoc Auth0 tenant. Provider tokens are not shown in command output.
|
|
42
|
+
|
|
43
|
+
Connect Google Drive or Microsoft 365 from the CLI before listing or writing files. The `connect` command opens the provider authorization page and waits for GPDoc to confirm the connection. The CLI never prints provider credentials.
|
|
25
44
|
|
|
26
|
-
|
|
45
|
+
```sh
|
|
46
|
+
gpdoc google connect
|
|
47
|
+
gpdoc microsoft connect
|
|
48
|
+
gpdoc google status
|
|
49
|
+
gpdoc microsoft status
|
|
50
|
+
|
|
51
|
+
# List the contents of My Drive or OneDrive. Add --shared for provider-shared entries.
|
|
52
|
+
gpdoc google list
|
|
53
|
+
gpdoc google list --shared
|
|
54
|
+
gpdoc microsoft list
|
|
55
|
+
gpdoc microsoft list --query "release plan"
|
|
56
|
+
|
|
57
|
+
# List GPDoc files you own and files others shared with your GPDoc account.
|
|
58
|
+
gpdoc shared list
|
|
59
|
+
```
|
|
27
60
|
|
|
28
|
-
|
|
61
|
+
Use explicit actions to avoid accidental remote writes:
|
|
29
62
|
|
|
30
63
|
```sh
|
|
31
64
|
# Create or revise a Google Docs source document.
|
|
@@ -43,7 +76,9 @@ gpdoc gist update GIST_ID docs/release-plan.gpdoc.md
|
|
|
43
76
|
gpdoc repo put repetere/example docs/release-plan.gpdoc.md --path docs/release-plan.gpdoc.md --branch main
|
|
44
77
|
```
|
|
45
78
|
|
|
46
|
-
Private Gists and private repositories require the same GPDoc GitHub-source entitlement as the VS Code extension. Google and Microsoft operations use GPDoc's provider API boundary; GitHub actions use the GitHub identity linked to the signed-in GPDoc account.
|
|
79
|
+
Uploads report preparation, transfer, and completion progress in an interactive terminal. Use `--json` for script-safe output without progress lines. Private Gists and private repositories require the same GPDoc GitHub-source entitlement as the VS Code extension. Google and Microsoft operations use GPDoc's provider API boundary; GitHub actions use the GitHub identity linked to the signed-in GPDoc account.
|
|
80
|
+
|
|
81
|
+
If a remote command reports `ACCOUNT_STATE_UNAVAILABLE`, GPDoc could not verify the signed-in account and intentionally did not send a provider request. Retry after the GPDoc account service recovers, then run `gpdoc google status` or `gpdoc microsoft status` to confirm the connection.
|
|
47
82
|
|
|
48
83
|
## Git Knowledge
|
|
49
84
|
|
package/bin/gpdoc.js
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
import { realpathSync } from 'node:fs';
|
|
4
4
|
import { access, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import process from 'node:process';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { createAuthManager } from '../lib/auth.js';
|
|
11
|
+
import { startGPEditorPreview } from '../lib/editor-preview.js';
|
|
10
12
|
import { createRemoteClient } from '../lib/remote.js';
|
|
11
13
|
const {
|
|
12
14
|
convertDocument,
|
|
@@ -19,6 +21,10 @@ const {
|
|
|
19
21
|
validateDocument,
|
|
20
22
|
} = await import('@gpdoc/filekit').catch(() => import('../../gpdoc-filekit/src/index.js'));
|
|
21
23
|
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const CLI_VERSION = require('../package.json').version;
|
|
26
|
+
const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
|
|
22
28
|
class CliError extends Error {
|
|
23
29
|
constructor(code, message) {
|
|
24
30
|
super(message);
|
|
@@ -27,7 +33,7 @@ class CliError extends Error {
|
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
function usage() {
|
|
30
|
-
return `Usage:\n gpdoc new OUTPUT [--title TITLE] [--json]\n gpdoc inspect INPUT [--json]\n gpdoc validate INPUT [--json]\n gpdoc convert INPUT --to gpdoc|markdown|html|text|json|docx|pdf [--output PATH | --in-place] [--json]\n gpdoc edit INPUT [--json]\n gpdoc login [complete] [--no-browser] [--json]\n gpdoc logout [--json]\n gpdoc whoami [--json]\n gpdoc google upload INPUT [--title TITLE] [--json]\n gpdoc google update INPUT --drive-id ID --item-id ID --revision REVISION [--json]\n gpdoc microsoft upload INPUT [--filename NAME] [--json]\n gpdoc microsoft update INPUT --drive-id ID --item-id ID [--json]\n gpdoc microsoft share --drive-id ID --item-id ID --role view|edit --scope anonymous|organization [--json]\n gpdoc gist create INPUT [--private] [--description TEXT] [--json]\n gpdoc gist update GIST_ID INPUT [--description TEXT] [--json]\n gpdoc repo put OWNER/REPOSITORY INPUT --path REMOTE_PATH [--branch BRANCH] [--message TEXT] [--json]\n gpdoc knowledge github-authorize [--json]\n gpdoc knowledge list [--json]\n gpdoc knowledge status REPOSITORY_ID [--json]\n gpdoc knowledge search REPOSITORY_ID[,REPOSITORY_ID] QUERY [--json]\n gpdoc knowledge connect GITHUB_REPOSITORY_ID [--json]\n gpdoc knowledge reindex REPOSITORY_ID [--json]\n gpdoc knowledge members REPOSITORY_ID [ACCOUNT_ID [ROLE]] [--json]\n gpdoc knowledge wiki-propose REPOSITORY_ID PATH CONTENT [--json]\n gpdoc knowledge disconnect REPOSITORY_ID --yes [--json]\n`;
|
|
36
|
+
return `Usage:\n gpdoc version [--json]\n gpdoc new OUTPUT [--title TITLE] [--json]\n gpdoc inspect INPUT [--json]\n gpdoc validate INPUT [--json]\n gpdoc convert INPUT --to gpdoc|markdown|html|text|json|docx|pdf [--output PATH | --in-place] [--json]\n gpdoc edit INPUT [--editor web|local] [--json]\n gpdoc login [complete] [--no-browser] [--json]\n gpdoc logout [--json]\n gpdoc whoami [--json]\n gpdoc google connect|status [--no-browser] [--json]\n gpdoc google list [--drive-id ID --parent-id ID] [--shared] [--cursor TOKEN] [--json]\n gpdoc google upload INPUT [--title TITLE] [--json]\n gpdoc google update INPUT --drive-id ID --item-id ID --revision REVISION [--json]\n gpdoc microsoft connect|status [--no-browser] [--json]\n gpdoc microsoft list [--query TEXT] [--drive-id ID --item-id ID] [--shared] [--cursor TOKEN] [--json]\n gpdoc microsoft upload INPUT [--filename NAME] [--json]\n gpdoc microsoft update INPUT --drive-id ID --item-id ID [--json]\n gpdoc microsoft share --drive-id ID --item-id ID --role view|edit --scope anonymous|organization [--json]\n gpdoc shared list [--json]\n gpdoc gist create INPUT [--private] [--description TEXT] [--json]\n gpdoc gist update GIST_ID INPUT [--description TEXT] [--json]\n gpdoc repo put OWNER/REPOSITORY INPUT --path REMOTE_PATH [--branch BRANCH] [--message TEXT] [--json]\n gpdoc knowledge github-authorize [--json]\n gpdoc knowledge list [--json]\n gpdoc knowledge status REPOSITORY_ID [--json]\n gpdoc knowledge search REPOSITORY_ID[,REPOSITORY_ID] QUERY [--json]\n gpdoc knowledge connect GITHUB_REPOSITORY_ID [--json]\n gpdoc knowledge reindex REPOSITORY_ID [--json]\n gpdoc knowledge members REPOSITORY_ID [ACCOUNT_ID [ROLE]] [--json]\n gpdoc knowledge wiki-propose REPOSITORY_ID PATH CONTENT [--json]\n gpdoc knowledge disconnect REPOSITORY_ID --yes [--json]\n`;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
function knowledgeError(response, payload) {
|
|
@@ -138,6 +144,7 @@ async function runKnowledge(argv, runtime = {}) {
|
|
|
138
144
|
|
|
139
145
|
function parseArguments(argv) {
|
|
140
146
|
const [command, ...rest] = argv;
|
|
147
|
+
if (['--version', '-v'].includes(command)) return parseArguments(['version', ...rest]);
|
|
141
148
|
if (!command || ['--help', '-h', 'help'].includes(command)) return { command: 'help', positionals: [], options: {} };
|
|
142
149
|
const positionals = [];
|
|
143
150
|
const options = {};
|
|
@@ -148,11 +155,11 @@ function parseArguments(argv) {
|
|
|
148
155
|
continue;
|
|
149
156
|
}
|
|
150
157
|
const key = value.slice(2);
|
|
151
|
-
if (['json', 'in-place', 'private', 'no-browser'].includes(key)) {
|
|
158
|
+
if (['json', 'in-place', 'private', 'no-browser', 'shared'].includes(key)) {
|
|
152
159
|
options[key] = true;
|
|
153
160
|
continue;
|
|
154
161
|
}
|
|
155
|
-
if (!['title', 'to', 'output', 'drive-id', 'item-id', 'revision', 'filetype', 'filename', 'role', 'scope', 'description', 'branch', 'path', 'message'].includes(key) || !rest[index + 1] || rest[index + 1].startsWith('--')) {
|
|
162
|
+
if (!['title', 'to', 'output', 'drive-id', 'item-id', 'parent-id', 'revision', 'filetype', 'filename', 'role', 'scope', 'description', 'branch', 'path', 'message', 'editor', 'cursor', 'query'].includes(key) || !rest[index + 1] || rest[index + 1].startsWith('--')) {
|
|
156
163
|
throw new CliError('USAGE', `Unknown or incomplete option: ${value}.`);
|
|
157
164
|
}
|
|
158
165
|
options[key] = rest[index + 1];
|
|
@@ -213,8 +220,9 @@ function printResult(result, json) {
|
|
|
213
220
|
|
|
214
221
|
// @spec CLI-029
|
|
215
222
|
function renderWhoami(status) {
|
|
216
|
-
const { identity, pending, ...fields } = status;
|
|
217
|
-
|
|
223
|
+
const { identity, pending, renewable, ...fields } = status;
|
|
224
|
+
if (renewable) fields.renewable = true;
|
|
225
|
+
const lines = Object.entries(fields).map(([key, value]) => `${key === 'expiresAt' ? 'accessTokenExpiresAt' : key}: ${value}`);
|
|
218
226
|
if (identity && typeof identity === 'object') {
|
|
219
227
|
const orderedClaims = ['email', 'name', 'nickname', 'sub'];
|
|
220
228
|
const claimEntries = [
|
|
@@ -306,10 +314,65 @@ function renderRemote(result, json) {
|
|
|
306
314
|
if (json) return printResult(result, true);
|
|
307
315
|
if (result.item) return printResult({ provider: result.provider, mode: result.mode, id: result.item.itemId || result.item.id, revision: result.item.revision || null, url: result.item.webUrl || null }, false);
|
|
308
316
|
if (result.share) return printResult({ provider: result.provider, url: result.share.webUrl || result.share.link?.webUrl || null, scope: result.share.scope || null }, false);
|
|
317
|
+
if (Object.hasOwn(result, 'connected')) return printResult({ provider: result.provider, connected: result.connected, ...(result.connection?.account ? { account: result.connection.account.displayName || result.connection.account.email || null } : {}) }, false);
|
|
309
318
|
return printResult(result, false);
|
|
310
319
|
}
|
|
311
320
|
|
|
312
|
-
|
|
321
|
+
function renderList(result, json) {
|
|
322
|
+
if (json) return printResult(result, true);
|
|
323
|
+
if (Array.isArray(result.items)) {
|
|
324
|
+
const rows = result.items.map((item) => {
|
|
325
|
+
const id = item?.providerData?.itemId || item?.itemId || item?.id || '';
|
|
326
|
+
return `${item?.kind || 'file'}\t${item?.name || 'Untitled'}\t${id}\t${item?.webUrl || ''}`;
|
|
327
|
+
});
|
|
328
|
+
return printResult(rows.length ? `kind\tname\tid\turl\n${rows.join('\n')}` : 'No files found.', false);
|
|
329
|
+
}
|
|
330
|
+
const owned = (result.owned || []).map((file) => `owned\t${file?.filename || 'Untitled'}\t${file?.shareId || ''}`);
|
|
331
|
+
const shared = (result.sharedWithMe || []).map((file) => `shared\t${file?.filename || 'Untitled'}\t${file?.shareId || ''}`);
|
|
332
|
+
return printResult([...owned, ...shared].length ? `access\tname\tshareId\n${[...owned, ...shared].join('\n')}` : 'No shared files found.', false);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function createProgressReporter(stderr = process.stderr) {
|
|
336
|
+
return (event) => {
|
|
337
|
+
if (!stderr?.isTTY || event?.stage === 'request') return;
|
|
338
|
+
const label = event.stage === 'preparing' ? 'Preparing'
|
|
339
|
+
: event.stage === 'uploading' ? 'Uploading'
|
|
340
|
+
: 'Complete';
|
|
341
|
+
const total = Math.max(1, Number(event.total || 1));
|
|
342
|
+
const current = Math.max(0, Math.min(total, Number(event.current || 0)));
|
|
343
|
+
const width = 18;
|
|
344
|
+
const filled = Math.round((current / total) * width);
|
|
345
|
+
stderr.write(`[gpdoc] ${label} ${event.provider || 'remote'} [${'#'.repeat(filled)}${'.'.repeat(width - filled)}] ${Math.round((current / total) * 100)}%\n`);
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function loadEditorHtml() {
|
|
350
|
+
try {
|
|
351
|
+
return await readFile(path.join(CLI_ROOT, 'web', 'editor.html'), 'utf8');
|
|
352
|
+
} catch {
|
|
353
|
+
throw new CliError('EDITOR_PREVIEW_UNAVAILABLE', 'GPEditor preview assets are unavailable. Reinstall @gpdoc/cli.');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function waitForPreview(preview) {
|
|
358
|
+
await new Promise((resolve) => {
|
|
359
|
+
process.once('SIGINT', resolve);
|
|
360
|
+
process.once('SIGTERM', resolve);
|
|
361
|
+
});
|
|
362
|
+
await preview.close();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function waitForProviderConnection(remote, providerName, sleep = delay) {
|
|
366
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
367
|
+
while (Date.now() < deadline) {
|
|
368
|
+
await sleep(2_000);
|
|
369
|
+
const status = await remote.providerStatus(providerName);
|
|
370
|
+
if (status.connected) return status;
|
|
371
|
+
}
|
|
372
|
+
throw new CliError('PROVIDER_CONNECTION_TIMEOUT', `Timed out waiting for ${providerName} connection. Run gpdoc ${providerName} status to check it later.`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// @spec CLI-001, CLI-002, CLI-003, CLI-004, CLI-005, CLI-006, CLI-007, CLI-008, CLI-009, CLI-014, CLI-015, CLI-016, CLI-017, CLI-018, CLI-019, CLI-020, CLI-021, CLI-022, CLI-023, CLI-024, CLI-025, CLI-028, CLI-030, CLI-032, CLI-033, CLI-034, CLI-035, CLI-036
|
|
313
376
|
export async function run(argv, runtime = {}) {
|
|
314
377
|
if (argv[0] === 'knowledge') {
|
|
315
378
|
await runKnowledge(argv.slice(1), runtime);
|
|
@@ -320,7 +383,11 @@ export async function run(argv, runtime = {}) {
|
|
|
320
383
|
process.stdout.write(usage());
|
|
321
384
|
return;
|
|
322
385
|
}
|
|
323
|
-
if (
|
|
386
|
+
if (parsed.command === 'version') {
|
|
387
|
+
printResult(parsed.options.json ? { version: CLI_VERSION } : CLI_VERSION, parsed.options.json);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (!['new', 'inspect', 'validate', 'convert', 'edit', 'login', 'logout', 'whoami', 'google', 'microsoft', 'shared', 'gist', 'repo'].includes(parsed.command)) {
|
|
324
391
|
throw new CliError('USAGE', `Unknown command: ${parsed.command}.`);
|
|
325
392
|
}
|
|
326
393
|
|
|
@@ -343,7 +410,8 @@ export async function run(argv, runtime = {}) {
|
|
|
343
410
|
else process.stdout.write(`Open ${verificationUrl} in a browser, then return here. GPDoc is waiting for authorization.\n`);
|
|
344
411
|
}
|
|
345
412
|
const completed = await waitForDeviceAuthorization(auth, runtime.sleep || delay);
|
|
346
|
-
|
|
413
|
+
const result = { ...started, browserOpened, ...completed };
|
|
414
|
+
printResult(parsed.options.json ? result : renderWhoami(result), parsed.options.json);
|
|
347
415
|
return;
|
|
348
416
|
}
|
|
349
417
|
if (parsed.command === 'logout') {
|
|
@@ -358,19 +426,44 @@ export async function run(argv, runtime = {}) {
|
|
|
358
426
|
|
|
359
427
|
if (parsed.command === 'google') {
|
|
360
428
|
const [action, input] = parsed.positionals;
|
|
429
|
+
if (action === 'status') return renderRemote(await remote.providerStatus('google'), parsed.options.json);
|
|
430
|
+
if (action === 'connect') {
|
|
431
|
+
const started = await remote.providerConnect('google');
|
|
432
|
+
const browserOpened = parsed.options['no-browser'] ? false : await (runtime.openBrowser || openBrowser)(started.launchUrl);
|
|
433
|
+
if (!parsed.options.json) process.stdout.write(browserOpened ? 'Opened your browser. Complete Google Drive authorization there; GPDoc is waiting.\n' : `Open ${started.launchUrl} in a browser to connect Google Drive. GPDoc is waiting.\n`);
|
|
434
|
+
const connected = await waitForProviderConnection(remote, 'google', runtime.sleep || delay);
|
|
435
|
+
return renderRemote({ ...started, browserOpened, ...connected }, parsed.options.json);
|
|
436
|
+
}
|
|
437
|
+
if (action === 'list') return renderList(await remote.googleList({ driveId: parsed.options['drive-id'], parentId: parsed.options['parent-id'], cursor: parsed.options.cursor, shared: parsed.options.shared === true }), parsed.options.json);
|
|
361
438
|
const filePath = await requireLocalInput(input);
|
|
362
|
-
|
|
363
|
-
if (action === '
|
|
364
|
-
|
|
439
|
+
const progress = parsed.options.json ? undefined : (runtime.progress || createProgressReporter(runtime.stderr));
|
|
440
|
+
if (action === 'upload') return renderRemote(await remote.googleSave({ filePath, title: parsed.options.title, filetype: parsed.options.filetype || 'document', onProgress: progress }), parsed.options.json);
|
|
441
|
+
if (action === 'update') return renderRemote(await remote.googleSave({ filePath, title: parsed.options.title, mode: 'update', driveId: requireOption(parsed.options, 'drive-id'), itemId: requireOption(parsed.options, 'item-id'), expectedRevision: requireOption(parsed.options, 'revision'), filetype: parsed.options.filetype || 'document', onProgress: progress }), parsed.options.json);
|
|
442
|
+
throw new CliError('USAGE', 'Use gpdoc google connect, status, list, upload, or update.');
|
|
365
443
|
}
|
|
366
444
|
|
|
367
445
|
if (parsed.command === 'microsoft') {
|
|
368
446
|
const [action, input] = parsed.positionals;
|
|
447
|
+
if (action === 'status') return renderRemote(await remote.providerStatus('microsoft'), parsed.options.json);
|
|
448
|
+
if (action === 'connect') {
|
|
449
|
+
const started = await remote.providerConnect('microsoft');
|
|
450
|
+
const browserOpened = parsed.options['no-browser'] ? false : await (runtime.openBrowser || openBrowser)(started.launchUrl);
|
|
451
|
+
if (!parsed.options.json) process.stdout.write(browserOpened ? 'Opened your browser. Complete Microsoft 365 authorization there; GPDoc is waiting.\n' : `Open ${started.launchUrl} in a browser to connect Microsoft 365. GPDoc is waiting.\n`);
|
|
452
|
+
const connected = await waitForProviderConnection(remote, 'microsoft', runtime.sleep || delay);
|
|
453
|
+
return renderRemote({ ...started, browserOpened, ...connected }, parsed.options.json);
|
|
454
|
+
}
|
|
455
|
+
if (action === 'list') return renderList(await remote.microsoftList({ driveId: parsed.options['drive-id'], itemId: parsed.options['item-id'], cursor: parsed.options.cursor, query: parsed.options.query, shared: parsed.options.shared === true }), parsed.options.json);
|
|
369
456
|
if (action === 'share') return renderRemote(await remote.microsoftShare({ driveId: requireOption(parsed.options, 'drive-id'), itemId: requireOption(parsed.options, 'item-id'), role: requireOption(parsed.options, 'role'), scope: requireOption(parsed.options, 'scope') }), parsed.options.json);
|
|
370
457
|
const filePath = await requireLocalInput(input);
|
|
371
|
-
|
|
372
|
-
if (action === '
|
|
373
|
-
|
|
458
|
+
const progress = parsed.options.json ? undefined : (runtime.progress || createProgressReporter(runtime.stderr));
|
|
459
|
+
if (action === 'upload') return renderRemote(await remote.microsoftSave({ filePath, filename: parsed.options.filename, onProgress: progress }), parsed.options.json);
|
|
460
|
+
if (action === 'update') return renderRemote(await remote.microsoftSave({ filePath, mode: 'update', driveId: requireOption(parsed.options, 'drive-id'), itemId: requireOption(parsed.options, 'item-id'), filename: parsed.options.filename, onProgress: progress }), parsed.options.json);
|
|
461
|
+
throw new CliError('USAGE', 'Use gpdoc microsoft connect, status, list, upload, update, or share.');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (parsed.command === 'shared') {
|
|
465
|
+
if (parsed.positionals[0] !== 'list') throw new CliError('USAGE', 'Use gpdoc shared list.');
|
|
466
|
+
return renderList(await remote.sharedList(), parsed.options.json);
|
|
374
467
|
}
|
|
375
468
|
|
|
376
469
|
if (parsed.command === 'gist') {
|
|
@@ -398,13 +491,25 @@ export async function run(argv, runtime = {}) {
|
|
|
398
491
|
const input = requireInput(parsed);
|
|
399
492
|
if (parsed.command === 'edit') {
|
|
400
493
|
if (input === '-') throw new CliError('USAGE', 'gpdoc edit requires a file path.');
|
|
401
|
-
const
|
|
402
|
-
|
|
494
|
+
const environment = runtime.env || process.env;
|
|
495
|
+
const surface = parsed.options.editor || environment.GPDOC_EDITOR || 'web';
|
|
403
496
|
await access(input);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
497
|
+
if (!['web', 'local'].includes(surface)) throw new CliError('USAGE', '--editor must be web or local.');
|
|
498
|
+
if (surface === 'local') {
|
|
499
|
+
const editor = environment.VISUAL || environment.EDITOR;
|
|
500
|
+
if (!editor) throw new CliError('EDITOR_UNAVAILABLE', 'Set $VISUAL or $EDITOR, or use the default web GPEditor.');
|
|
501
|
+
await launchEditor(editor, input);
|
|
502
|
+
const { document } = await loadDocument(input);
|
|
503
|
+
validateDocument(document);
|
|
504
|
+
printResult({ valid: true, path: input, format: document.format }, parsed.options.json);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const preview = await (runtime.startPreview || startGPEditorPreview)({ filePath: input, editorHtml: runtime.editorHtml || await loadEditorHtml() });
|
|
508
|
+
const browserOpened = await (runtime.openBrowser || openBrowser)(preview.url);
|
|
509
|
+
const result = { editor: 'gpeditor', url: preview.url, browserOpened, path: path.resolve(input) };
|
|
510
|
+
printResult(result, parsed.options.json);
|
|
511
|
+
if (!browserOpened) process.stdout.write(`Open ${preview.url} in a browser. Keep this command running while you edit.\n`);
|
|
512
|
+
await (runtime.waitForPreview || waitForPreview)(preview);
|
|
408
513
|
return;
|
|
409
514
|
}
|
|
410
515
|
|
package/lib/auth.js
CHANGED
|
@@ -82,7 +82,7 @@ async function requestJson(fetchImpl, url, init) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// @spec CLI-016, CLI-017, CLI-018, CLI-019, CLI-025
|
|
85
|
+
// @spec CLI-016, CLI-017, CLI-018, CLI-019, CLI-025, CLI-031
|
|
86
86
|
export async function createAuthManager({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
|
87
87
|
const target = credentialPath(env);
|
|
88
88
|
const externalToken = env.GPDOC_ACCESS_TOKEN?.trim() || undefined;
|
|
@@ -98,14 +98,16 @@ export async function createAuthManager({ env = process.env, fetchImpl = globalT
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
async function status() {
|
|
101
|
-
if (externalToken) return { authenticated: true, source: 'environment', externallyManaged: true, expiresAt: null };
|
|
101
|
+
if (externalToken) return { authenticated: true, source: 'environment', externallyManaged: true, expiresAt: null, renewable: false };
|
|
102
102
|
const credentials = await load();
|
|
103
103
|
const active = typeof credentials.accessToken === 'string' && Number(credentials.expiresAt || 0) > Date.now();
|
|
104
|
+
const renewable = typeof credentials.refreshToken === 'string' && credentials.refreshToken.length > 0;
|
|
104
105
|
return {
|
|
105
|
-
authenticated: active,
|
|
106
|
-
source: active ? 'device' : null,
|
|
106
|
+
authenticated: active || renewable,
|
|
107
|
+
source: active || renewable ? 'device' : null,
|
|
107
108
|
externallyManaged: false,
|
|
108
109
|
expiresAt: credentials.expiresAt ? new Date(credentials.expiresAt).toISOString() : null,
|
|
110
|
+
renewable,
|
|
109
111
|
identity: credentials.identity || null,
|
|
110
112
|
pending: Boolean(credentials.pending && Number(credentials.pending.expiresAt || 0) > Date.now()),
|
|
111
113
|
};
|
|
@@ -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
|
+
}
|