@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 CHANGED
@@ -1,11 +1,12 @@
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
@@ -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` 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.
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
- 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.
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
- After the associated Google Drive, Microsoft 365, and GitHub accounts are connected in GPDoc, use explicit actions to avoid accidental remote writes:
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
- 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}`);
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
- // @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
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 (!['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)) {
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
- printResult({ ...started, browserOpened, ...completed }, parsed.options.json);
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
- if (action === 'upload') return renderRemote(await remote.googleSave({ filePath, title: parsed.options.title, filetype: parsed.options.filetype || 'document' }), parsed.options.json);
363
- 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);
364
- 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.');
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
- if (action === 'upload') return renderRemote(await remote.microsoftSave({ filePath, filename: parsed.options.filename }), parsed.options.json);
372
- 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);
373
- 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);
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 editor = process.env.VISUAL || process.env.EDITOR;
402
- 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';
403
496
  await access(input);
404
- await launchEditor(editor, input);
405
- const { document } = await loadDocument(input);
406
- validateDocument(document);
407
- 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);
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
+ }