@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
package/README.md
CHANGED
|
@@ -1,28 +1,64 @@
|
|
|
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
|
|
12
13
|
npm run cli -- edit docs/notes.gpdoc.md
|
|
14
|
+
npm run cli -- knowledge list --json
|
|
15
|
+
npm run cli -- knowledge search repo_123 "deployment review" --json
|
|
16
|
+
npm run cli -- knowledge github-authorize
|
|
13
17
|
```
|
|
14
18
|
|
|
15
19
|
Supported input formats are Markdown, managed GPDoc Markdown, HTML, plain text, GPDoc JSON documents, DOCX, and PPTX text. Supported outputs are `gpdoc`, `markdown`, `html`, `text`, `json`, `docx`, and `pdf`. DOCX/PPTX conversion preserves text and headings, not full presentation layout; PDF output is text-only. PDF input, RTF input, and PPTX output return an explicit unsupported-format error.
|
|
16
20
|
|
|
17
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.
|
|
18
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
|
+
|
|
19
37
|
## Account and remote providers
|
|
20
38
|
|
|
21
|
-
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.
|
|
22
42
|
|
|
23
|
-
|
|
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.
|
|
24
44
|
|
|
25
|
-
|
|
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
|
+
```
|
|
60
|
+
|
|
61
|
+
Use explicit actions to avoid accidental remote writes:
|
|
26
62
|
|
|
27
63
|
```sh
|
|
28
64
|
# Create or revise a Google Docs source document.
|
|
@@ -40,7 +76,13 @@ gpdoc gist update GIST_ID docs/release-plan.gpdoc.md
|
|
|
40
76
|
gpdoc repo put repetere/example docs/release-plan.gpdoc.md --path docs/release-plan.gpdoc.md --branch main
|
|
41
77
|
```
|
|
42
78
|
|
|
43
|
-
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.
|
|
82
|
+
|
|
83
|
+
## Git Knowledge
|
|
84
|
+
|
|
85
|
+
Git Knowledge commands call the GPDoc Cloudflare Worker and use `GPDOC_ACCESS_TOKEN`. They never receive GitHub App credentials, Cloudflare credentials, or source-index bindings. Run `gpdoc knowledge github-authorize` to use the GitHub App device flow, then use `connect` with a GitHub repository ID. After authorization, the CLI can list, inspect, search, connect, reindex, manage members, propose reviewable wiki changes, and disconnect repositories. Use `--json` for machine-readable cited search results.
|
|
44
86
|
|
|
45
87
|
## Installation
|
|
46
88
|
|
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,11 +33,118 @@ 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`;
|
|
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`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function knowledgeError(response, payload) {
|
|
40
|
+
const error = new CliError(payload?.code || 'GIT_KNOWLEDGE_REQUEST_FAILED', payload?.error || `Git Knowledge request failed (${response.status}).`);
|
|
41
|
+
error.status = response.status;
|
|
42
|
+
return error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function knowledgeRequest(pathname, options = {}, runtime = {}) {
|
|
46
|
+
const env = runtime.env || process.env;
|
|
47
|
+
const accessToken = String(env.GPDOC_ACCESS_TOKEN || '').trim();
|
|
48
|
+
if (!accessToken) throw new CliError('AUTH_REQUIRED', 'Set GPDOC_ACCESS_TOKEN from a GPDoc device login before using Git Knowledge.');
|
|
49
|
+
const baseUrl = String(env.GPDOC_API_BASE_URL || 'https://gpdoc.io').trim().replace(/\/+$/, '');
|
|
50
|
+
const response = await fetch(`${baseUrl}/api/git-knowledge${pathname}`, {
|
|
51
|
+
...options,
|
|
52
|
+
headers: { authorization: `Bearer ${accessToken}`, ...(options.body ? { 'content-type': 'application/json' } : {}), ...(options.headers || {}) },
|
|
53
|
+
});
|
|
54
|
+
const payload = await response.json().catch(() => ({}));
|
|
55
|
+
if (!response.ok) throw knowledgeError(response, payload);
|
|
56
|
+
return payload;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function runKnowledge(argv, runtime = {}) {
|
|
60
|
+
const request = (pathname, options) => knowledgeRequest(pathname, options, runtime);
|
|
61
|
+
const json = argv.includes('--json');
|
|
62
|
+
const args = argv.filter((value) => value !== '--json');
|
|
63
|
+
const [command, ...positionals] = args;
|
|
64
|
+
if (!command || ['help', '--help', '-h'].includes(command)) {
|
|
65
|
+
process.stdout.write(usage());
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (command === 'github-authorize') {
|
|
69
|
+
const started = await request('/github/device/authorizations', { method: 'POST', body: '{}' });
|
|
70
|
+
if (json) printResult({ state: started.state, verificationUri: started.verificationUriComplete || started.verificationUri, userCode: started.userCode, expiresAt: started.expiresAt }, true);
|
|
71
|
+
else process.stdout.write(`Open ${started.verificationUriComplete || started.verificationUri} and enter code: ${started.userCode}\n`);
|
|
72
|
+
const deadline = new Date(started.expiresAt).getTime();
|
|
73
|
+
let intervalMs = Math.max(5, Number(started.intervalSeconds || 5)) * 1000;
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
76
|
+
const completed = await request('/github/device/authorizations/complete', { method: 'POST', body: '{}' });
|
|
77
|
+
if (completed.state === 'complete') {
|
|
78
|
+
if (!json) process.stdout.write('GitHub authorization completed. You can now connect a repository.\n');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
intervalMs = Math.max(5, Number(completed.intervalSeconds || intervalMs / 1000)) * 1000;
|
|
82
|
+
}
|
|
83
|
+
throw new CliError('GITHUB_DEVICE_AUTH_EXPIRED', 'GitHub device authorization expired. Run gpdoc knowledge github-authorize again.');
|
|
84
|
+
}
|
|
85
|
+
if (command === 'list') {
|
|
86
|
+
const result = await request('/repositories');
|
|
87
|
+
printResult(result.repositories || [], json);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (command === 'status') {
|
|
91
|
+
const repositoryId = positionals[0];
|
|
92
|
+
if (!repositoryId) throw new CliError('USAGE', 'gpdoc knowledge status requires a repository id.');
|
|
93
|
+
const result = await request(`/repositories/${encodeURIComponent(repositoryId)}`);
|
|
94
|
+
printResult(result.repository || result, json);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (command === 'search') {
|
|
98
|
+
const [scope, ...queryParts] = positionals;
|
|
99
|
+
if (!scope || !queryParts.length) throw new CliError('USAGE', 'gpdoc knowledge search requires repository ids and a query.');
|
|
100
|
+
const result = await request('/search', { method: 'POST', body: JSON.stringify({ repositoryIds: scope.split(',').map((value) => value.trim()).filter(Boolean), query: queryParts.join(' ') }) });
|
|
101
|
+
printResult(json ? result : (result.results || []).map((entry) => `${entry.citation}\n${entry.excerpt}\n`).join('\n') || 'No cited results.', json);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (command === 'connect') {
|
|
105
|
+
const githubRepositoryId = positionals[0];
|
|
106
|
+
if (!githubRepositoryId) throw new CliError('USAGE', 'gpdoc knowledge connect requires a GitHub repository id.');
|
|
107
|
+
const result = await request('/repositories', { method: 'POST', body: JSON.stringify({ githubRepositoryId }) });
|
|
108
|
+
printResult(result.repository || result, json);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (command === 'reindex') {
|
|
112
|
+
const repositoryId = positionals[0];
|
|
113
|
+
if (!repositoryId) throw new CliError('USAGE', 'gpdoc knowledge reindex requires a repository id.');
|
|
114
|
+
const result = await request(`/repositories/${encodeURIComponent(repositoryId)}/ingest`, { method: 'POST', body: '{}' });
|
|
115
|
+
printResult(result, json);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (command === 'members') {
|
|
119
|
+
const [repositoryId, accountId, role = 'viewer'] = positionals;
|
|
120
|
+
if (!repositoryId) throw new CliError('USAGE', 'gpdoc knowledge members requires a repository id.');
|
|
121
|
+
const result = accountId
|
|
122
|
+
? await request(`/repositories/${encodeURIComponent(repositoryId)}/members`, { method: 'POST', body: JSON.stringify({ accountId, role }) })
|
|
123
|
+
: await request(`/repositories/${encodeURIComponent(repositoryId)}/members`);
|
|
124
|
+
printResult(result.member || result.members || result, json);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (command === 'wiki-propose') {
|
|
128
|
+
const [repositoryId, pathValue, ...contentParts] = positionals;
|
|
129
|
+
if (!repositoryId || !pathValue || !contentParts.length) throw new CliError('USAGE', 'gpdoc knowledge wiki-propose requires a repository id, wiki path, and Markdown content.');
|
|
130
|
+
const result = await request(`/repositories/${encodeURIComponent(repositoryId)}/wiki-update`, { method: 'POST', body: JSON.stringify({ changes: [{ path: pathValue, content: contentParts.join(' ') }] }) });
|
|
131
|
+
printResult(result, json);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (command === 'disconnect') {
|
|
135
|
+
const repositoryId = positionals[0];
|
|
136
|
+
if (!repositoryId || !argv.includes('--yes')) throw new CliError('USAGE', 'gpdoc knowledge disconnect requires a repository id and --yes.');
|
|
137
|
+
const result = await request(`/repositories/${encodeURIComponent(repositoryId)}`, { method: 'DELETE' });
|
|
138
|
+
printResult(result, json);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
throw new CliError('USAGE', `Unknown Git Knowledge command: ${command}.`);
|
|
31
142
|
}
|
|
32
143
|
|
|
144
|
+
|
|
33
145
|
function parseArguments(argv) {
|
|
34
146
|
const [command, ...rest] = argv;
|
|
147
|
+
if (['--version', '-v'].includes(command)) return parseArguments(['version', ...rest]);
|
|
35
148
|
if (!command || ['--help', '-h', 'help'].includes(command)) return { command: 'help', positionals: [], options: {} };
|
|
36
149
|
const positionals = [];
|
|
37
150
|
const options = {};
|
|
@@ -42,11 +155,11 @@ function parseArguments(argv) {
|
|
|
42
155
|
continue;
|
|
43
156
|
}
|
|
44
157
|
const key = value.slice(2);
|
|
45
|
-
if (['json', 'in-place', 'private', 'no-browser'].includes(key)) {
|
|
158
|
+
if (['json', 'in-place', 'private', 'no-browser', 'shared'].includes(key)) {
|
|
46
159
|
options[key] = true;
|
|
47
160
|
continue;
|
|
48
161
|
}
|
|
49
|
-
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('--')) {
|
|
50
163
|
throw new CliError('USAGE', `Unknown or incomplete option: ${value}.`);
|
|
51
164
|
}
|
|
52
165
|
options[key] = rest[index + 1];
|
|
@@ -107,8 +220,9 @@ function printResult(result, json) {
|
|
|
107
220
|
|
|
108
221
|
// @spec CLI-029
|
|
109
222
|
function renderWhoami(status) {
|
|
110
|
-
const { identity, pending, ...fields } = status;
|
|
111
|
-
|
|
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}`);
|
|
112
226
|
if (identity && typeof identity === 'object') {
|
|
113
227
|
const orderedClaims = ['email', 'name', 'nickname', 'sub'];
|
|
114
228
|
const claimEntries = [
|
|
@@ -200,17 +314,80 @@ function renderRemote(result, json) {
|
|
|
200
314
|
if (json) return printResult(result, true);
|
|
201
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);
|
|
202
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);
|
|
203
318
|
return printResult(result, false);
|
|
204
319
|
}
|
|
205
320
|
|
|
206
|
-
|
|
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
|
|
207
376
|
export async function run(argv, runtime = {}) {
|
|
377
|
+
if (argv[0] === 'knowledge') {
|
|
378
|
+
await runKnowledge(argv.slice(1), runtime);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
208
381
|
const parsed = parseArguments(argv);
|
|
209
382
|
if (parsed.command === 'help') {
|
|
210
383
|
process.stdout.write(usage());
|
|
211
384
|
return;
|
|
212
385
|
}
|
|
213
|
-
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)) {
|
|
214
391
|
throw new CliError('USAGE', `Unknown command: ${parsed.command}.`);
|
|
215
392
|
}
|
|
216
393
|
|
|
@@ -233,7 +410,8 @@ export async function run(argv, runtime = {}) {
|
|
|
233
410
|
else process.stdout.write(`Open ${verificationUrl} in a browser, then return here. GPDoc is waiting for authorization.\n`);
|
|
234
411
|
}
|
|
235
412
|
const completed = await waitForDeviceAuthorization(auth, runtime.sleep || delay);
|
|
236
|
-
|
|
413
|
+
const result = { ...started, browserOpened, ...completed };
|
|
414
|
+
printResult(parsed.options.json ? result : renderWhoami(result), parsed.options.json);
|
|
237
415
|
return;
|
|
238
416
|
}
|
|
239
417
|
if (parsed.command === 'logout') {
|
|
@@ -248,19 +426,44 @@ export async function run(argv, runtime = {}) {
|
|
|
248
426
|
|
|
249
427
|
if (parsed.command === 'google') {
|
|
250
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);
|
|
251
438
|
const filePath = await requireLocalInput(input);
|
|
252
|
-
|
|
253
|
-
if (action === '
|
|
254
|
-
|
|
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.');
|
|
255
443
|
}
|
|
256
444
|
|
|
257
445
|
if (parsed.command === 'microsoft') {
|
|
258
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);
|
|
259
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);
|
|
260
457
|
const filePath = await requireLocalInput(input);
|
|
261
|
-
|
|
262
|
-
if (action === '
|
|
263
|
-
|
|
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);
|
|
264
467
|
}
|
|
265
468
|
|
|
266
469
|
if (parsed.command === 'gist') {
|
|
@@ -288,13 +491,25 @@ export async function run(argv, runtime = {}) {
|
|
|
288
491
|
const input = requireInput(parsed);
|
|
289
492
|
if (parsed.command === 'edit') {
|
|
290
493
|
if (input === '-') throw new CliError('USAGE', 'gpdoc edit requires a file path.');
|
|
291
|
-
const
|
|
292
|
-
|
|
494
|
+
const environment = runtime.env || process.env;
|
|
495
|
+
const surface = parsed.options.editor || environment.GPDOC_EDITOR || 'web';
|
|
293
496
|
await access(input);
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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);
|
|
298
513
|
return;
|
|
299
514
|
}
|
|
300
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
|
};
|