@gpdoc/cli 1.7.0 → 1.8.1

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
@@ -54,6 +54,17 @@ gpdoc google list --shared
54
54
  gpdoc microsoft list
55
55
  gpdoc microsoft list --query "release plan"
56
56
 
57
+ # The table identifies folders and document kinds. When another page exists,
58
+ # GPDoc tells you to run --next. Use --cursor only for scripts.
59
+ gpdoc google list --next
60
+ gpdoc google list --cursor 'CURSOR_FROM_JSON'
61
+
62
+ # Open an item in its provider, or import a supported document into a new
63
+ # local GPDoc Markdown file and open it in GPEditor.
64
+ gpdoc google list --open 7
65
+ gpdoc google list --open-gpdoc 7 --output docs/imported-plan.gpdoc.md
66
+ gpdoc microsoft list --open-gpdoc 3
67
+
57
68
  # List GPDoc files you own and files others shared with your GPDoc account.
58
69
  gpdoc shared list
59
70
  ```
@@ -78,6 +89,8 @@ gpdoc repo put repetere/example docs/release-plan.gpdoc.md --path docs/release-p
78
89
 
79
90
  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
91
 
92
+ `--open-gpdoc ROW` supports Google Docs and Microsoft Word documents. It imports the selected document into a new managed local `.gpdoc.md` file, preserves source details as GPDoc metadata, opens that local copy in GPEditor, and leaves the original Google Drive or Microsoft 365 file unchanged. If `--output` is omitted, GPDoc creates a safe filename in the current directory and refuses to replace an existing file.
93
+
81
94
  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
95
 
83
96
  ## Git Knowledge
package/bin/gpdoc.js CHANGED
@@ -9,6 +9,7 @@ import process from 'node:process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { createAuthManager } from '../lib/auth.js';
11
11
  import { startGPEditorPreview } from '../lib/editor-preview.js';
12
+ import { createListState } from '../lib/list-state.js';
12
13
  import { createRemoteClient } from '../lib/remote.js';
13
14
  const {
14
15
  convertDocument,
@@ -33,7 +34,7 @@ class CliError extends Error {
33
34
  }
34
35
 
35
36
  function usage() {
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
+ 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 | --next] [--open ROW | --open-gpdoc ROW [--output PATH]] [--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 | --next] [--open ROW | --open-gpdoc ROW [--output PATH]] [--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
  }
38
39
 
39
40
  function knowledgeError(response, payload) {
@@ -155,11 +156,11 @@ function parseArguments(argv) {
155
156
  continue;
156
157
  }
157
158
  const key = value.slice(2);
158
- if (['json', 'in-place', 'private', 'no-browser', 'shared'].includes(key)) {
159
+ if (['json', 'in-place', 'private', 'no-browser', 'shared', 'next'].includes(key)) {
159
160
  options[key] = true;
160
161
  continue;
161
162
  }
162
- if (!['title', 'to', 'output', 'drive-id', 'item-id', 'parent-id', 'revision', 'filetype', 'filename', 'role', 'scope', 'description', 'branch', 'path', 'message', 'editor', 'cursor', 'query', 'open'].includes(key) || !rest[index + 1] || rest[index + 1].startsWith('--')) {
163
+ if (!['title', 'to', 'output', 'drive-id', 'item-id', 'parent-id', 'revision', 'filetype', 'filename', 'role', 'scope', 'description', 'branch', 'path', 'message', 'editor', 'cursor', 'query', 'open', 'open-gpdoc'].includes(key) || !rest[index + 1] || rest[index + 1].startsWith('--')) {
163
164
  throw new CliError('USAGE', `Unknown or incomplete option: ${value}.`);
164
165
  }
165
166
  options[key] = rest[index + 1];
@@ -348,6 +349,76 @@ function listedItemId(item) {
348
349
  return item?.providerData?.itemId || item?.itemId || item?.id || '';
349
350
  }
350
351
 
352
+ function displayItemKind(item) {
353
+ if (item?.kind === 'folder' || item?.kind === 'drive') return 'Folder';
354
+ const mimeType = String(item?.mimeType || '').toLowerCase();
355
+ const name = String(item?.name || '').toLowerCase();
356
+ if (mimeType === 'application/vnd.google-apps.document') return 'Google Doc';
357
+ if (mimeType === 'application/vnd.google-apps.spreadsheet') return 'Google Sheet';
358
+ if (mimeType === 'application/vnd.google-apps.presentation') return 'Google Slides';
359
+ if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || name.endsWith('.docx')) return 'Word document';
360
+ if (mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || name.endsWith('.pptx')) return 'PowerPoint';
361
+ if (mimeType === 'application/pdf' || name.endsWith('.pdf')) return 'PDF';
362
+ if (/markdown|text\/x-markdown/.test(mimeType) || /\.md(?:own)?$/.test(name)) return 'Markdown';
363
+ if (mimeType === 'text/csv' || name.endsWith('.csv')) return 'CSV';
364
+ return 'File';
365
+ }
366
+
367
+ function canOpenInGPEditor(provider, item) {
368
+ if (item?.kind === 'folder' || item?.kind === 'drive') return false;
369
+ const kind = displayItemKind(item);
370
+ return provider === 'google' ? kind === 'Google Doc' : provider === 'microsoft' ? kind === 'Word document' : false;
371
+ }
372
+
373
+ function nextPageCommand(provider, cursor) {
374
+ return cursor ? `gpdoc ${provider} list --next` : null;
375
+ }
376
+
377
+ function listContext(provider, options) {
378
+ const keys = provider === 'google'
379
+ ? ['driveId', 'parentId', 'shared', 'cursor']
380
+ : ['query', 'driveId', 'itemId', 'shared', 'cursor'];
381
+ const source = {
382
+ driveId: options['drive-id'],
383
+ parentId: options['parent-id'],
384
+ itemId: options['item-id'],
385
+ query: options.query,
386
+ shared: options.shared === true,
387
+ cursor: options.cursor,
388
+ };
389
+ return Object.fromEntries(keys.filter((key) => source[key] === true || typeof source[key] === 'string').map((key) => [key, source[key]]));
390
+ }
391
+
392
+ async function resolveListContext(provider, options, state) {
393
+ if (!options.next) return listContext(provider, options);
394
+ if (options.cursor || options['drive-id'] || options['parent-id'] || options['item-id'] || options.query || options.shared || options.open || options['open-gpdoc'] || options.output || options.json) {
395
+ throw new CliError('USAGE', '--next cannot be combined with another list option.');
396
+ }
397
+ const saved = await state.load(provider);
398
+ if (!saved) throw new CliError('NEXT_PAGE_UNAVAILABLE', `No next ${displayProvider(provider)} page is available. Run gpdoc ${provider} list first.`);
399
+ return saved;
400
+ }
401
+
402
+ function defaultImportedPath(item) {
403
+ const original = String(item?.name || 'imported-document').trim();
404
+ const filename = path.basename(original).replace(/\.[^.]+$/, '').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '') || 'imported-document';
405
+ return path.resolve(`${filename}.gpdoc.md`);
406
+ }
407
+
408
+ function createImportedGpdoc({ provider, item, imported }) {
409
+ const title = String(imported?.document?.title || item?.name || 'Imported document').trim() || 'Imported document';
410
+ const body = imported?.document?.body;
411
+ if (typeof body !== 'string') throw new CliError('GPDOC_IMPORT_UNSUPPORTED', 'GPDoc could not convert the selected provider item into a document.');
412
+ const metadata = createManagedDocument(title, body);
413
+ metadata.meta = {
414
+ data: { source: provider === 'google' ? 'google-drive' : 'microsoft-365' },
415
+ integrations: provider === 'google'
416
+ ? { google: { fileId: imported.item?.itemId || listedItemId(item), driveId: imported.item?.driveId || item?.providerData?.driveId || null, mimeType: item?.mimeType || imported.item?.mimeType || null } }
417
+ : { microsoft: { itemId: imported.item?.itemId || listedItemId(item), driveId: imported.item?.driveId || item?.providerData?.driveId || null, mimeType: item?.mimeType || imported.item?.mimeType || null } },
418
+ };
419
+ return serializeManagedMarkdown(metadata, body);
420
+ }
421
+
351
422
  // @spec CLI-038
352
423
  export function selectListedItem(items, row) {
353
424
  const index = Number(row);
@@ -358,18 +429,18 @@ export function selectListedItem(items, row) {
358
429
  }
359
430
 
360
431
  // @spec CLI-038
361
- export function formatList(result) {
432
+ export function formatList(result, { nextCommand } = {}) {
362
433
  if (Array.isArray(result.items)) {
363
434
  if (!result.items.length) return 'No files found.';
364
435
  const columns = [
365
- ['#', 3], ['Type', 8], ['Name', 34], ['Modified', 10], ['Size', 9], ['ID', 18],
436
+ ['#', 3], ['Kind', 15], ['Name', 29], ['Modified', 10], ['Size', 9], ['ID', 18],
366
437
  ];
367
438
  const header = columns.map(([label, width]) => label.padEnd(width)).join(' ').trimEnd();
368
439
  const divider = columns.map(([, width]) => '─'.repeat(width)).join(' ');
369
440
  const rows = result.items.map((item, index) => {
370
441
  const values = [
371
442
  String(index + 1),
372
- item?.kind === 'folder' ? 'Folder' : 'File',
443
+ displayItemKind(item),
373
444
  item?.name || 'Untitled',
374
445
  formatDate(item?.modifiedAt),
375
446
  item?.kind === 'folder' ? '—' : formatBytes(item?.size),
@@ -378,7 +449,8 @@ export function formatList(result) {
378
449
  return values.map((value, columnIndex) => truncate(value, columns[columnIndex][1]).padEnd(columns[columnIndex][1])).join(' ').trimEnd();
379
450
  });
380
451
  const provider = result.provider || 'provider';
381
- return `${displayProvider(provider)}\n${header}\n${divider}\n${rows.join('\n')}\n\nOpen an item: gpdoc ${provider} list --open ROW\nUse --json for full IDs and URLs.`;
452
+ const next = nextCommand ? `\n\nMore files are available. Next page:\n${nextCommand}` : '';
453
+ return `${displayProvider(provider)}\n${header}\n${divider}\n${rows.join('\n')}${next}\n\nOpen in provider: gpdoc ${provider} list --open ROW\nOpen a supported document in GPDoc: gpdoc ${provider} list --open-gpdoc ROW\nUse --json for full IDs and URLs.`;
382
454
  }
383
455
  const entries = [
384
456
  ...(result.owned || []).map((file) => ({ access: 'Owned', name: file?.filename || 'Untitled', id: file?.shareId || '' })),
@@ -395,14 +467,40 @@ function renderList(result, json) {
395
467
  return printResult(formatList(result), false);
396
468
  }
397
469
 
398
- async function renderProviderList(result, { open, json, runtime }) {
399
- if (!open) return renderList(result, json);
400
- if (json) throw new CliError('USAGE', 'Use either --open ROW or --json, not both.');
401
- const item = selectListedItem(result.items || [], open);
402
- if (!item?.webUrl) throw new CliError('PROVIDER_URL_UNAVAILABLE', 'The selected provider item has no browser URL. Use --json to inspect its identifiers.');
403
- renderList(result, false);
404
- const browserOpened = await (runtime.openBrowser || openBrowser)(item.webUrl);
405
- process.stdout.write(browserOpened ? `Opened ${item.name || 'provider item'} in your browser.\n` : `Could not open a browser. Open this URL manually: ${item.webUrl}\n`);
470
+ async function renderProviderList(result, { open, openGpdoc, output, json, provider, remote, runtime }) {
471
+ const formatted = () => formatList(result, { nextCommand: nextPageCommand(provider, result.cursor) });
472
+ if (!open && !openGpdoc) {
473
+ if (json) return printResult(result, true);
474
+ return printResult(formatted(), false);
475
+ }
476
+ if (json || (open && openGpdoc)) throw new CliError('USAGE', 'Use one of --open ROW, --open-gpdoc ROW, or --json.');
477
+ const item = selectListedItem(result.items || [], open || openGpdoc);
478
+ if (open) {
479
+ if (!item?.webUrl) throw new CliError('PROVIDER_URL_UNAVAILABLE', 'The selected provider item has no browser URL. Use --json to inspect its identifiers.');
480
+ printResult(formatted(), false);
481
+ const browserOpened = await (runtime.openBrowser || openBrowser)(item.webUrl);
482
+ process.stdout.write(browserOpened ? `Opened ${item.name || 'provider item'} in your browser.\n` : `Could not open a browser. Open this URL manually: ${item.webUrl}\n`);
483
+ return;
484
+ }
485
+ if (!canOpenInGPEditor(provider, item)) {
486
+ throw new CliError('GPDOC_IMPORT_UNSUPPORTED', `Only ${provider === 'google' ? 'Google Docs' : 'Microsoft Word documents'} can open in the CLI GPEditor. The selected ${displayItemKind(item)} remains available with --open.`);
487
+ }
488
+ const target = path.resolve(output || defaultImportedPath(item));
489
+ if (await exists(target)) throw new CliError('OUTPUT_EXISTS', `Refusing to overwrite existing output: ${target}. Choose another --output path.`);
490
+ const ids = { driveId: item?.providerData?.driveId, itemId: listedItemId(item) };
491
+ const imported = provider === 'google'
492
+ ? await remote.googleImport(ids)
493
+ : await remote.microsoftImport(ids);
494
+ await atomicWrite(target, createImportedGpdoc({ provider, item, imported }));
495
+ const preview = await (runtime.startPreview || startGPEditorPreview)({
496
+ filePath: target,
497
+ editorHtml: runtime.editorHtml ?? (runtime.startPreview ? '' : await loadEditorHtml()),
498
+ });
499
+ const browserOpened = await (runtime.openBrowser || openBrowser)(preview.url);
500
+ printResult(formatted(), false);
501
+ printResult({ imported: target, editor: 'gpeditor', browserOpened, url: preview.url }, false);
502
+ if (!browserOpened) process.stdout.write(`Open ${preview.url} in a browser. Keep this command running while you edit.\n`);
503
+ await (runtime.waitForPreview || waitForPreview)(preview);
406
504
  }
407
505
 
408
506
  function createProgressReporter(stderr = process.stderr) {
@@ -445,7 +543,7 @@ async function waitForProviderConnection(remote, providerName, sleep = delay) {
445
543
  throw new CliError('PROVIDER_CONNECTION_TIMEOUT', `Timed out waiting for ${providerName} connection. Run gpdoc ${providerName} status to check it later.`);
446
544
  }
447
545
 
448
- // @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, CLI-038
546
+ // @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, CLI-038, CLI-039
449
547
  export async function run(argv, runtime = {}) {
450
548
  if (argv[0] === 'knowledge') {
451
549
  await runKnowledge(argv.slice(1), runtime);
@@ -454,7 +552,7 @@ export async function run(argv, runtime = {}) {
454
552
  const parsed = parseArguments(argv);
455
553
  if (parsed.command === 'help') {
456
554
  process.stdout.write(usage());
457
- process.stdout.write('\nCloud file lists: use gpdoc google list --open ROW or gpdoc microsoft list --open ROW to open a numbered item in your browser.\n');
555
+ process.stdout.write('\nCloud file lists: use --open ROW for the provider, --open-gpdoc ROW to import a supported document into GPEditor, and --next for the next page.\n');
458
556
  return;
459
557
  }
460
558
  if (parsed.command === 'version') {
@@ -508,7 +606,13 @@ export async function run(argv, runtime = {}) {
508
606
  const connected = await waitForProviderConnection(remote, 'google', runtime.sleep || delay);
509
607
  return renderRemote({ ...started, browserOpened, ...connected }, parsed.options.json);
510
608
  }
511
- if (action === 'list') return renderProviderList(await remote.googleList({ driveId: parsed.options['drive-id'], parentId: parsed.options['parent-id'], cursor: parsed.options.cursor, shared: parsed.options.shared === true }), { open: parsed.options.open, json: parsed.options.json, runtime });
609
+ if (action === 'list') {
610
+ const state = runtime.listState || createListState({ env: runtime.env || process.env });
611
+ const context = await resolveListContext('google', parsed.options, state);
612
+ const result = await remote.googleList(context);
613
+ if (!parsed.options.json) await state.save('google', result.cursor ? { ...context, cursor: result.cursor } : null);
614
+ return renderProviderList(result, { open: parsed.options.open, openGpdoc: parsed.options['open-gpdoc'], output: parsed.options.output, json: parsed.options.json, provider: 'google', remote, runtime });
615
+ }
512
616
  const filePath = await requireLocalInput(input);
513
617
  const progress = parsed.options.json ? undefined : (runtime.progress || createProgressReporter(runtime.stderr));
514
618
  if (action === 'upload') return renderRemote(await remote.googleSave({ filePath, title: parsed.options.title, filetype: parsed.options.filetype || 'document', onProgress: progress }), parsed.options.json);
@@ -526,7 +630,13 @@ export async function run(argv, runtime = {}) {
526
630
  const connected = await waitForProviderConnection(remote, 'microsoft', runtime.sleep || delay);
527
631
  return renderRemote({ ...started, browserOpened, ...connected }, parsed.options.json);
528
632
  }
529
- if (action === 'list') return renderProviderList(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 }), { open: parsed.options.open, json: parsed.options.json, runtime });
633
+ if (action === 'list') {
634
+ const state = runtime.listState || createListState({ env: runtime.env || process.env });
635
+ const context = await resolveListContext('microsoft', parsed.options, state);
636
+ const result = await remote.microsoftList(context);
637
+ if (!parsed.options.json) await state.save('microsoft', result.cursor ? { ...context, cursor: result.cursor } : null);
638
+ return renderProviderList(result, { open: parsed.options.open, openGpdoc: parsed.options['open-gpdoc'], output: parsed.options.output, json: parsed.options.json, provider: 'microsoft', remote, runtime });
639
+ }
530
640
  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);
531
641
  const filePath = await requireLocalInput(input);
532
642
  const progress = parsed.options.json ? undefined : (runtime.progress || createProgressReporter(runtime.stderr));
@@ -3,6 +3,7 @@ import { createServer } from 'node:http';
3
3
  import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  const {
6
+ createManagedDocument,
6
7
  readDocument,
7
8
  serializeManagedMarkdown,
8
9
  validateDocument,
@@ -102,11 +103,44 @@ function contentForSave(snapshot, content) {
102
103
  return content;
103
104
  }
104
105
 
106
+ function isPlainObject(value) {
107
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
108
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
109
+ }
110
+
111
+ // @spec CLI-040, CLI-042, CLI-043, CLI-044
112
+ function metadataForSave(snapshot, metadata) {
113
+ if (!isPlainObject(metadata)) {
114
+ throw new EditorPreviewError('INVALID_INPUT', 'Front matter metadata must be an object.');
115
+ }
116
+ const currentMetadata = snapshot.document.managed
117
+ ? snapshot.document.metadata
118
+ : createManagedDocument(snapshot.document.title, snapshot.document.body);
119
+ const nextMetadata = {
120
+ ...currentMetadata,
121
+ ...metadata,
122
+ content: { document: snapshot.document.body },
123
+ };
124
+ return serializeManagedMarkdown(nextMetadata, snapshot.document.body);
125
+ }
126
+
127
+ function frontMatterFor(snapshot) {
128
+ const metadata = snapshot.document.managed
129
+ ? snapshot.document.metadata
130
+ : createManagedDocument(snapshot.document.title, snapshot.document.body);
131
+ return {
132
+ managed: snapshot.document.managed,
133
+ metadata,
134
+ };
135
+ }
136
+
105
137
  // @spec CLI-032
106
138
  export async function startGPEditorPreview({ filePath, editorHtml }) {
107
139
  if (!editorHtml) throw new EditorPreviewError('EDITOR_PREVIEW_UNAVAILABLE', 'GPEditor preview assets are unavailable. Reinstall @gpdoc/cli.');
108
140
  const target = path.resolve(filePath);
109
- const localEditorHtml = editorHtml.replace(/<\/head>/i, '<style>.share-action{display:none!important}</style></head>');
141
+ const localEditorHtml = editorHtml
142
+ .replace(/<html([^>]*)>/i, '<html$1 data-gpdoc-cli-editor>')
143
+ .replace(/<\/head>/i, '<style>.share-action{display:none!important}</style></head>');
110
144
  await readSnapshot(target);
111
145
  const token = crypto.randomBytes(32).toString('base64url');
112
146
  let origin = '';
@@ -116,6 +150,7 @@ export async function startGPEditorPreview({ filePath, editorHtml }) {
116
150
  const url = new URL(request.url || '/', origin || 'http://127.0.0.1');
117
151
  const editorPath = `/editor/${token}`;
118
152
  const apiPath = `/api/editor/${token}`;
153
+ const frontMatterPath = `${apiPath}/front-matter`;
119
154
  if (request.method === 'GET' && url.pathname === editorPath) {
120
155
  response.writeHead(200, {
121
156
  'Content-Type': 'text/html; charset=utf-8',
@@ -127,7 +162,7 @@ export async function startGPEditorPreview({ filePath, editorHtml }) {
127
162
  response.end(localEditorHtml);
128
163
  return;
129
164
  }
130
- if (url.pathname !== apiPath && url.pathname !== `${apiPath}/save`) {
165
+ if (url.pathname !== apiPath && url.pathname !== `${apiPath}/save` && url.pathname !== frontMatterPath) {
131
166
  sendJson(response, 404, { error: { code: 'ROUTE_NOT_FOUND', message: 'The editor route was not found.' } });
132
167
  return;
133
168
  }
@@ -140,7 +175,12 @@ export async function startGPEditorPreview({ filePath, editorHtml }) {
140
175
  sendJson(response, 200, { editor });
141
176
  return;
142
177
  }
143
- if (request.method !== 'POST' || url.pathname !== `${apiPath}/save`) {
178
+ if (request.method === 'GET' && url.pathname === frontMatterPath) {
179
+ const snapshot = await readSnapshot(target);
180
+ sendJson(response, 200, { frontMatter: frontMatterFor(snapshot) });
181
+ return;
182
+ }
183
+ if (request.method !== 'POST' || (url.pathname !== `${apiPath}/save` && url.pathname !== frontMatterPath)) {
144
184
  sendJson(response, 405, { error: { code: 'METHOD_NOT_ALLOWED', message: 'The editor operation does not support this method.' } });
145
185
  return;
146
186
  }
@@ -149,12 +189,16 @@ export async function startGPEditorPreview({ filePath, editorHtml }) {
149
189
  return;
150
190
  }
151
191
  const input = await readJson(request);
152
- if (typeof input.content !== 'string') throw new EditorPreviewError('INVALID_INPUT', 'Editor content must be text.');
153
192
  const snapshot = await readSnapshot(target);
154
193
  if (String(input.expectedRevision || '') !== snapshot.editor.revision) {
155
194
  throw new EditorPreviewError('REVISION_CONFLICT', 'The local file changed. Reload the editor before saving.');
156
195
  }
157
- const nextSource = contentForSave(snapshot, input.content);
196
+ const nextSource = url.pathname === frontMatterPath
197
+ ? metadataForSave(snapshot, input.metadata)
198
+ : (() => {
199
+ if (typeof input.content !== 'string') throw new EditorPreviewError('INVALID_INPUT', 'Editor content must be text.');
200
+ return contentForSave(snapshot, input.content);
201
+ })();
158
202
  const prospective = snapshotFor(target, nextSource);
159
203
  validateDocument(prospective.document);
160
204
  await atomicWrite(target, nextSource);
@@ -0,0 +1,51 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ function defaultPath(env = process.env) {
6
+ const configured = String(env.GPDOC_LIST_STATE_PATH || '').trim();
7
+ return configured || path.join(homedir(), '.config', 'gpdoc', 'list-state.json');
8
+ }
9
+
10
+ function sanitizeContext(value) {
11
+ if (!value || typeof value !== 'object') return null;
12
+ const result = {};
13
+ for (const key of ['driveId', 'parentId', 'itemId', 'query', 'cursor']) {
14
+ if (typeof value[key] === 'string' && value[key]) result[key] = value[key];
15
+ }
16
+ if (value.shared === true) result.shared = true;
17
+ return result.cursor ? result : null;
18
+ }
19
+
20
+ // @spec CLI-039
21
+ export function createListState({ env = process.env, filePath = defaultPath(env) } = {}) {
22
+ async function readState() {
23
+ try {
24
+ const parsed = JSON.parse(await readFile(filePath, 'utf8'));
25
+ return parsed && typeof parsed === 'object' ? parsed : {};
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ return {
32
+ async load(provider) {
33
+ const state = await readState();
34
+ return sanitizeContext(state?.providers?.[provider]);
35
+ },
36
+
37
+ async save(provider, context) {
38
+ const state = await readState();
39
+ const providers = { ...(state.providers && typeof state.providers === 'object' ? state.providers : {}) };
40
+ const sanitized = sanitizeContext(context);
41
+ if (sanitized) providers[provider] = sanitized;
42
+ else delete providers[provider];
43
+ const directory = path.dirname(filePath);
44
+ await mkdir(directory, { recursive: true, mode: 0o700 });
45
+ const temporary = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
46
+ await writeFile(temporary, `${JSON.stringify({ version: 1, providers })}\n`, { mode: 0o600 });
47
+ await rename(temporary, filePath);
48
+ return sanitized;
49
+ },
50
+ };
51
+ }
package/lib/remote.js CHANGED
@@ -96,7 +96,7 @@ function githubHeaders(token) {
96
96
  return { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28' };
97
97
  }
98
98
 
99
- // @spec CLI-019, CLI-020, CLI-021, CLI-022, CLI-023, CLI-024, CLI-025, CLI-037
99
+ // @spec CLI-019, CLI-020, CLI-021, CLI-022, CLI-023, CLI-024, CLI-025, CLI-037, CLI-039
100
100
  export function createRemoteClient({ auth, env = process.env, fetchImpl = globalThis.fetch } = {}) {
101
101
  if (!auth) throw new Error('Remote client requires an auth manager.');
102
102
  async function provider(functionName, body, method = 'POST', onProgress) {
@@ -197,6 +197,36 @@ export function createRemoteClient({ auth, env = process.env, fetchImpl = global
197
197
  return { provider: 'microsoft', items, cursor: payload.cursor || payload.continuation || null };
198
198
  },
199
199
 
200
+ async googleImport({ driveId, itemId }) {
201
+ if (!driveId || !itemId) throw new RemoteError('USAGE', 'Google GPDoc import requires a drive id and item id.');
202
+ const payload = await provider('google-drive-source-import', { driveId, itemId });
203
+ const body = payload?.content?.document;
204
+ if (payload?.filetype !== 'document' || typeof body !== 'string') {
205
+ throw new RemoteError('GPDOC_IMPORT_UNSUPPORTED', 'Only Google Docs can open in the CLI GPEditor. Open Google Slides in Google Drive.');
206
+ }
207
+ return {
208
+ provider: 'google',
209
+ item: payload.item || { driveId, itemId },
210
+ document: { title: payload.filename || payload.item?.name || 'Imported Google Doc', body },
211
+ };
212
+ },
213
+
214
+ async microsoftImport({ driveId, itemId }) {
215
+ if (!driveId || !itemId) throw new RemoteError('USAGE', 'Microsoft GPDoc import requires a drive id and item id.');
216
+ const payload = await provider('microsoft-drive-import', { driveId, itemId });
217
+ const bytes = typeof payload?.bytes === 'string' ? Buffer.from(payload.bytes, 'base64') : null;
218
+ if (!bytes?.byteLength) throw new RemoteError('GPDOC_IMPORT_UNSUPPORTED', 'Microsoft 365 did not return a Word document for GPDoc import.');
219
+ const document = readDocument(bytes, payload.item?.name || 'imported.docx');
220
+ if (document.filetype !== 'document') {
221
+ throw new RemoteError('GPDOC_IMPORT_UNSUPPORTED', 'Only Microsoft Word documents can open in the CLI GPEditor.');
222
+ }
223
+ return {
224
+ provider: 'microsoft',
225
+ item: payload.item || { driveId, itemId },
226
+ document: { title: payload.item?.name || document.title || 'Imported Word document', body: document.body },
227
+ };
228
+ },
229
+
200
230
  async sharedList() {
201
231
  const token = await auth.getAccessToken();
202
232
  const payload = await jsonRequest(fetchImpl, `${apiBase(env)}/api/shared-files`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gpdoc/cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "type": "module",
5
5
  "description": "GPDoc command-line file conversion and validation tools",
6
6
  "repository": "https://github.com/repetere/gpdoc.git",