@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 CHANGED
@@ -1,28 +1,64 @@
1
1
  # GPDoc CLI
2
2
 
3
- The GPDoc CLI creates, inspects, validates, converts, and publishes document files from a terminal. It keeps body editing in GPEditor or the editor configured through `$VISUAL` or `$EDITOR`.
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` reports the CLI session without including credentials. `gpdoc logout` clears only the local CLI credential file.
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
- 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. Provider tokens are not shown in command output.
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
- After the associated Google Drive, Microsoft 365, and GitHub accounts are connected in GPDoc, use explicit actions to avoid accidental remote writes:
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
- const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`);
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
- // @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
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 (!['new', 'inspect', 'validate', 'convert', 'edit', 'login', 'logout', 'whoami', 'google', 'microsoft', 'gist', 'repo'].includes(parsed.command)) {
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
- printResult({ ...started, browserOpened, ...completed }, parsed.options.json);
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
- if (action === 'upload') return renderRemote(await remote.googleSave({ filePath, title: parsed.options.title, filetype: parsed.options.filetype || 'document' }), parsed.options.json);
253
- 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' }), parsed.options.json);
254
- throw new CliError('USAGE', 'Use gpdoc google upload INPUT or gpdoc google update INPUT.');
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
- if (action === 'upload') return renderRemote(await remote.microsoftSave({ filePath, filename: parsed.options.filename }), parsed.options.json);
262
- 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 }), parsed.options.json);
263
- throw new CliError('USAGE', 'Use gpdoc microsoft upload, update, or share.');
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 editor = process.env.VISUAL || process.env.EDITOR;
292
- if (!editor) throw new CliError('EDITOR_UNAVAILABLE', 'Set $VISUAL or $EDITOR before using gpdoc edit.');
494
+ const environment = runtime.env || process.env;
495
+ const surface = parsed.options.editor || environment.GPDOC_EDITOR || 'web';
293
496
  await access(input);
294
- await launchEditor(editor, input);
295
- const { document } = await loadDocument(input);
296
- validateDocument(document);
297
- printResult({ valid: true, path: input, format: document.format }, parsed.options.json);
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
  };