@gpdoc/cli 1.0.0 → 1.1.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
@@ -1,6 +1,6 @@
1
1
  # GPDoc CLI
2
2
 
3
- The GPDoc CLI creates, inspects, validates, and converts local document files. It keeps rich editing in GPEditor or the editor configured through `$VISUAL` or `$EDITOR`.
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`.
4
4
 
5
5
  From this repository, run commands through `npm run cli --`:
6
6
 
@@ -16,6 +16,32 @@ Supported input formats are Markdown, managed GPDoc Markdown, HTML, plain text,
16
16
 
17
17
  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
18
 
19
+ ## Account and remote providers
20
+
21
+ Use `gpdoc login` to begin GPDoc's device authorization flow, open the printed verification URL in a browser, and then run `gpdoc login complete`. `gpdoc whoami --json` reports the CLI session without including credentials. `gpdoc logout` clears only the local CLI credential file.
22
+
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.
24
+
25
+ After the associated Google Drive, Microsoft 365, and GitHub accounts are connected in GPDoc, use explicit actions to avoid accidental remote writes:
26
+
27
+ ```sh
28
+ # Create or revise a Google Docs source document.
29
+ gpdoc google upload docs/release-plan.gpdoc.md --title "Release plan"
30
+ gpdoc google update docs/release-plan.gpdoc.md --drive-id DRIVE_ID --item-id ITEM_ID --revision REVISION
31
+
32
+ # Create, revise, or share a Microsoft 365 file.
33
+ gpdoc microsoft upload docs/release-plan.gpdoc.md
34
+ gpdoc microsoft update docs/release-plan.gpdoc.md --drive-id DRIVE_ID --item-id ITEM_ID
35
+ gpdoc microsoft share --drive-id DRIVE_ID --item-id ITEM_ID --role edit --scope organization
36
+
37
+ # Create or revise a GitHub Gist or a repository file.
38
+ gpdoc gist create docs/release-plan.gpdoc.md --description "Release plan"
39
+ gpdoc gist update GIST_ID docs/release-plan.gpdoc.md
40
+ gpdoc repo put repetere/example docs/release-plan.gpdoc.md --path docs/release-plan.gpdoc.md --branch main
41
+ ```
42
+
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.
44
+
19
45
  ## Installation
20
46
 
21
47
  After the public npm release, install with `npm install --global @gpdoc/cli`, or run one command without installation through `npx --yes @gpdoc/cli`. The initial distribution target is npm because the CLI requires Node.js 20 or later on every supported platform. A Homebrew formula and signed macOS, Windows, and Linux downloads should follow once release artifacts and signing are in place.
package/bin/gpdoc.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // @spec CLI-001, CLI-002, CLI-003, CLI-004, CLI-005, CLI-006, CLI-007, CLI-008, CLI-009, CLI-014, CLI-015
3
+ import { realpathSync } from 'node:fs';
3
4
  import { access, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
4
5
  import { spawn } from 'node:child_process';
5
6
  import path from 'node:path';
6
7
  import process from 'node:process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { createAuthManager } from '../lib/auth.js';
10
+ import { createRemoteClient } from '../lib/remote.js';
7
11
  const {
8
12
  convertDocument,
9
13
  createManagedDocument,
@@ -23,7 +27,7 @@ class CliError extends Error {
23
27
  }
24
28
 
25
29
  function usage() {
26
- 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`;
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] [--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`;
27
31
  }
28
32
 
29
33
  function parseArguments(argv) {
@@ -38,11 +42,11 @@ function parseArguments(argv) {
38
42
  continue;
39
43
  }
40
44
  const key = value.slice(2);
41
- if (['json', 'in-place'].includes(key)) {
45
+ if (['json', 'in-place', 'private'].includes(key)) {
42
46
  options[key] = true;
43
47
  continue;
44
48
  }
45
- if (!['title', 'to', 'output'].includes(key) || !rest[index + 1] || rest[index + 1].startsWith('--')) {
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('--')) {
46
50
  throw new CliError('USAGE', `Unknown or incomplete option: ${value}.`);
47
51
  }
48
52
  options[key] = rest[index + 1];
@@ -115,16 +119,84 @@ function launchEditor(editor, target) {
115
119
  });
116
120
  }
117
121
 
118
- export async function run(argv) {
122
+ function requireOption(options, key, message) {
123
+ if (!options[key]) throw new CliError('USAGE', message || `--${key} is required.`);
124
+ return options[key];
125
+ }
126
+
127
+ async function requireLocalInput(input) {
128
+ if (!input || input === '-' || /^[a-z][a-z0-9+.-]*:\/\//i.test(input)) {
129
+ throw new CliError('USAGE', 'Remote provider commands require a local file path.');
130
+ }
131
+ await access(input);
132
+ return input;
133
+ }
134
+
135
+ function renderRemote(result, json) {
136
+ if (json) return printResult(result, true);
137
+ 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);
138
+ if (result.share) return printResult({ provider: result.provider, url: result.share.webUrl || result.share.link?.webUrl || null, scope: result.share.scope || null }, false);
139
+ return printResult(result, false);
140
+ }
141
+
142
+ // @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
143
+ export async function run(argv, runtime = {}) {
119
144
  const parsed = parseArguments(argv);
120
145
  if (parsed.command === 'help') {
121
146
  process.stdout.write(usage());
122
147
  return;
123
148
  }
124
- if (!['new', 'inspect', 'validate', 'convert', 'edit'].includes(parsed.command)) {
149
+ if (!['new', 'inspect', 'validate', 'convert', 'edit', 'login', 'logout', 'whoami', 'google', 'microsoft', 'gist', 'repo'].includes(parsed.command)) {
125
150
  throw new CliError('USAGE', `Unknown command: ${parsed.command}.`);
126
151
  }
127
152
 
153
+ const auth = runtime.auth || await createAuthManager({ env: runtime.env || process.env, fetchImpl: runtime.fetchImpl || globalThis.fetch });
154
+ const remote = runtime.remote || createRemoteClient({ auth, env: runtime.env || process.env, fetchImpl: runtime.fetchImpl || globalThis.fetch });
155
+
156
+ if (parsed.command === 'login') {
157
+ const result = parsed.positionals[0] === 'complete' ? await auth.complete() : await auth.start();
158
+ printResult(result, parsed.options.json);
159
+ return;
160
+ }
161
+ if (parsed.command === 'logout') {
162
+ printResult(await auth.logout(), parsed.options.json);
163
+ return;
164
+ }
165
+ if (parsed.command === 'whoami') {
166
+ printResult(await auth.status(), parsed.options.json);
167
+ return;
168
+ }
169
+
170
+ if (parsed.command === 'google') {
171
+ const [action, input] = parsed.positionals;
172
+ const filePath = await requireLocalInput(input);
173
+ if (action === 'upload') return renderRemote(await remote.googleSave({ filePath, title: parsed.options.title, filetype: parsed.options.filetype || 'document' }), parsed.options.json);
174
+ 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);
175
+ throw new CliError('USAGE', 'Use gpdoc google upload INPUT or gpdoc google update INPUT.');
176
+ }
177
+
178
+ if (parsed.command === 'microsoft') {
179
+ const [action, input] = parsed.positionals;
180
+ 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);
181
+ const filePath = await requireLocalInput(input);
182
+ if (action === 'upload') return renderRemote(await remote.microsoftSave({ filePath, filename: parsed.options.filename }), parsed.options.json);
183
+ 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);
184
+ throw new CliError('USAGE', 'Use gpdoc microsoft upload, update, or share.');
185
+ }
186
+
187
+ if (parsed.command === 'gist') {
188
+ const [action, first, second] = parsed.positionals;
189
+ if (action === 'create') return renderRemote(await remote.gistCreate({ filePath: await requireLocalInput(first), description: parsed.options.description, isPrivate: parsed.options.private === true }), parsed.options.json);
190
+ if (action === 'update') return renderRemote(await remote.gistUpdate({ gistId: first, filePath: await requireLocalInput(second), description: parsed.options.description }), parsed.options.json);
191
+ throw new CliError('USAGE', 'Use gpdoc gist create INPUT or gpdoc gist update GIST_ID INPUT.');
192
+ }
193
+
194
+ if (parsed.command === 'repo') {
195
+ const [action, repository, input] = parsed.positionals;
196
+ if (action !== 'put') throw new CliError('USAGE', 'Use gpdoc repo put OWNER/REPOSITORY INPUT --path REMOTE_PATH.');
197
+ return renderRemote(await remote.repoPut({ repository, filePath: await requireLocalInput(input), remotePath: requireOption(parsed.options, 'path'), branch: parsed.options.branch || 'main', message: parsed.options.message }), parsed.options.json);
198
+ }
199
+
128
200
  if (parsed.command === 'new') {
129
201
  const target = requireInput(parsed);
130
202
  if (await exists(target)) throw new CliError('OUTPUT_EXISTS', `Refusing to overwrite existing output: ${target}. Use --in-place only for the source file.`);
@@ -180,10 +252,21 @@ export async function run(argv) {
180
252
  else if (warnings.length) process.stderr.write(`${warnings.join('\n')}\n`);
181
253
  }
182
254
 
183
- try {
184
- await run(process.argv.slice(2));
185
- } catch (error) {
186
- const json = process.argv.includes('--json');
187
- printError(error, json);
188
- process.exitCode = 1;
255
+ function isCliEntrypoint() {
256
+ if (!process.argv[1]) return false;
257
+ try {
258
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
259
+ } catch {
260
+ return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
261
+ }
262
+ }
263
+
264
+ if (isCliEntrypoint()) {
265
+ try {
266
+ await run(process.argv.slice(2));
267
+ } catch (error) {
268
+ const json = process.argv.includes('--json');
269
+ printError(error, json);
270
+ process.exitCode = 1;
271
+ }
189
272
  }
package/lib/auth.js ADDED
@@ -0,0 +1,192 @@
1
+ import { chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export class AuthError extends Error {
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
9
+ }
10
+ }
11
+
12
+ const DEFAULT_ISSUER = 'https://auth.gpdoc.io/';
13
+ const DEFAULT_AUDIENCE = 'https://gpdoc.us.auth0.com/api/v2/';
14
+ const DEFAULT_CLIENT_ID = 'diSOuWs44rqN7SaDLdE2csL4S0efgI1l';
15
+
16
+ function credentialPath(env) {
17
+ if (env.GPDOC_CREDENTIALS_PATH) return path.resolve(env.GPDOC_CREDENTIALS_PATH);
18
+ const configHome = env.XDG_CONFIG_HOME || path.join(homedir(), '.config');
19
+ return path.join(configHome, 'gpdoc', 'credentials.json');
20
+ }
21
+
22
+ async function readCredentials(target) {
23
+ try {
24
+ const details = await stat(target);
25
+ if ((details.mode & 0o077) !== 0) await chmod(target, 0o600);
26
+ const parsed = JSON.parse(await readFile(target, 'utf8'));
27
+ return parsed && typeof parsed === 'object' ? parsed : {};
28
+ } catch (error) {
29
+ if (error?.code === 'ENOENT') return {};
30
+ throw new AuthError('AUTH_STORE_UNAVAILABLE', 'GPDoc could not read the local credential store.');
31
+ }
32
+ }
33
+
34
+ async function writeCredentials(target, value) {
35
+ try {
36
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
37
+ await chmod(path.dirname(target), 0o700).catch(() => {});
38
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
39
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
40
+ await chmod(temporary, 0o600);
41
+ await rename(temporary, target);
42
+ } catch {
43
+ throw new AuthError('AUTH_STORE_UNAVAILABLE', 'GPDoc could not save the local credential store.');
44
+ }
45
+ }
46
+
47
+ function decodeIdentity(token) {
48
+ if (typeof token !== 'string') return undefined;
49
+ try {
50
+ const [, payload] = token.split('.');
51
+ const claims = JSON.parse(Buffer.from(payload || '', 'base64url').toString('utf8'));
52
+ return Object.fromEntries(['sub', 'email', 'name', 'nickname']
53
+ .filter((key) => typeof claims?.[key] === 'string')
54
+ .map((key) => [key, claims[key]]));
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function configuration(env) {
61
+ const issuerInput = env.GPDOC_AUTH0_ISSUER || env.GPDOC_AUTH0_DOMAIN || DEFAULT_ISSUER;
62
+ const issuer = (issuerInput.startsWith('http') ? issuerInput : `https://${issuerInput}`).replace(/\/?$/, '/');
63
+ return {
64
+ issuer,
65
+ audience: env.GPDOC_AUTH0_AUDIENCE || DEFAULT_AUDIENCE,
66
+ clientId: env.GPDOC_AUTH0_CLIENT_ID || DEFAULT_CLIENT_ID,
67
+ deviceAuthorizationEndpoint: new URL('oauth/device/code', issuer).toString(),
68
+ tokenEndpoint: new URL('oauth/token', issuer).toString(),
69
+ };
70
+ }
71
+
72
+ async function requestJson(fetchImpl, url, init) {
73
+ try {
74
+ const response = await fetchImpl(url, init);
75
+ return { response, payload: await response.json().catch(() => ({})) };
76
+ } catch {
77
+ throw new AuthError('AUTH_FAILED', 'GPDoc authentication could not be reached.');
78
+ }
79
+ }
80
+
81
+ // @spec CLI-016, CLI-017, CLI-018, CLI-019, CLI-025
82
+ export async function createAuthManager({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
83
+ const target = credentialPath(env);
84
+ const externalToken = env.GPDOC_ACCESS_TOKEN?.trim() || undefined;
85
+ const config = configuration(env);
86
+
87
+ async function load() {
88
+ return readCredentials(target);
89
+ }
90
+
91
+ async function save(next) {
92
+ if (externalToken) return;
93
+ await writeCredentials(target, next);
94
+ }
95
+
96
+ async function status() {
97
+ if (externalToken) return { authenticated: true, source: 'environment', externallyManaged: true, expiresAt: null };
98
+ const credentials = await load();
99
+ const active = typeof credentials.accessToken === 'string' && Number(credentials.expiresAt || 0) > Date.now();
100
+ return {
101
+ authenticated: active,
102
+ source: active ? 'device' : null,
103
+ externallyManaged: false,
104
+ expiresAt: credentials.expiresAt ? new Date(credentials.expiresAt).toISOString() : null,
105
+ identity: credentials.identity || null,
106
+ pending: Boolean(credentials.pending && Number(credentials.pending.expiresAt || 0) > Date.now()),
107
+ };
108
+ }
109
+
110
+ async function start() {
111
+ if (externalToken) throw new AuthError('AUTH_EXTERNALLY_MANAGED', 'Authentication is managed by GPDOC_ACCESS_TOKEN.');
112
+ const { response, payload } = await requestJson(fetchImpl, config.deviceAuthorizationEndpoint, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
115
+ body: new URLSearchParams({ client_id: config.clientId, audience: config.audience }),
116
+ });
117
+ if (!response.ok || typeof payload.device_code !== 'string' || typeof payload.user_code !== 'string' || typeof payload.verification_uri !== 'string') {
118
+ throw new AuthError('AUTH_FAILED', 'GPDoc could not start device authorization.');
119
+ }
120
+ const intervalSeconds = Math.max(1, Number(payload.interval || 5));
121
+ const expiresInSeconds = Math.max(1, Number(payload.expires_in || 600));
122
+ await save({ pending: {
123
+ deviceCode: payload.device_code,
124
+ userCode: payload.user_code,
125
+ verificationUri: payload.verification_uri,
126
+ verificationUriComplete: typeof payload.verification_uri_complete === 'string' ? payload.verification_uri_complete : undefined,
127
+ expiresAt: Date.now() + expiresInSeconds * 1000,
128
+ intervalSeconds,
129
+ nextPollAt: Date.now(),
130
+ } });
131
+ return {
132
+ status: 'pending', userCode: payload.user_code, verificationUri: payload.verification_uri,
133
+ verificationUriComplete: payload.verification_uri_complete, expiresInSeconds, retryAfterSeconds: intervalSeconds,
134
+ };
135
+ }
136
+
137
+ async function complete() {
138
+ if (externalToken) return status();
139
+ const credentials = await load();
140
+ const pending = credentials.pending;
141
+ if (!pending) throw new AuthError('AUTH_NOT_PENDING', 'Run gpdoc login before completing device authorization.');
142
+ if (Number(pending.expiresAt || 0) <= Date.now()) {
143
+ await save({});
144
+ return { status: 'expired', authenticated: false };
145
+ }
146
+ if (Number(pending.nextPollAt || 0) > Date.now()) return { status: 'pending', authenticated: false, retryAfterSeconds: Math.ceil((pending.nextPollAt - Date.now()) / 1000) };
147
+ const { response, payload } = await requestJson(fetchImpl, config.tokenEndpoint, {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
150
+ body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: pending.deviceCode, client_id: config.clientId }),
151
+ });
152
+ if (!response.ok) {
153
+ if (payload.error === 'authorization_pending' || payload.error === 'slow_down') {
154
+ const intervalSeconds = Math.min(60, Number(pending.intervalSeconds || 5) + (payload.error === 'slow_down' ? 5 : 0));
155
+ await save({ pending: { ...pending, intervalSeconds, nextPollAt: Date.now() + intervalSeconds * 1000 } });
156
+ return { status: 'pending', authenticated: false, retryAfterSeconds: intervalSeconds };
157
+ }
158
+ await save({});
159
+ return { status: payload.error === 'access_denied' ? 'denied' : 'failed', authenticated: false };
160
+ }
161
+ if (typeof payload.access_token !== 'string') return { status: 'failed', authenticated: false };
162
+ const identity = decodeIdentity(payload.id_token) || decodeIdentity(payload.access_token);
163
+ await save({ accessToken: payload.access_token, refreshToken: payload.refresh_token, expiresAt: Date.now() + Math.max(1, Number(payload.expires_in || 3600)) * 1000, identity });
164
+ return { status: 'authenticated', ...(await status()) };
165
+ }
166
+
167
+ async function getAccessToken() {
168
+ if (externalToken) return externalToken;
169
+ const credentials = await load();
170
+ if (typeof credentials.accessToken === 'string' && Number(credentials.expiresAt || 0) > Date.now()) return credentials.accessToken;
171
+ if (typeof credentials.refreshToken !== 'string') throw new AuthError('AUTH_REQUIRED', 'Sign in with gpdoc login before using remote providers.');
172
+ const { response, payload } = await requestJson(fetchImpl, config.tokenEndpoint, {
173
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
174
+ body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: credentials.refreshToken, client_id: config.clientId }),
175
+ });
176
+ if (!response.ok || typeof payload.access_token !== 'string') {
177
+ await save({});
178
+ throw new AuthError('AUTH_REQUIRED', 'The GPDoc session expired. Sign in again.');
179
+ }
180
+ const next = { accessToken: payload.access_token, refreshToken: payload.refresh_token || credentials.refreshToken, expiresAt: Date.now() + Math.max(1, Number(payload.expires_in || 3600)) * 1000, identity: decodeIdentity(payload.id_token) || credentials.identity };
181
+ await save(next);
182
+ return next.accessToken;
183
+ }
184
+
185
+ async function logout() {
186
+ if (externalToken) throw new AuthError('AUTH_EXTERNALLY_MANAGED', 'GPDOC_ACCESS_TOKEN is managed outside this process.');
187
+ await save({});
188
+ return { authenticated: false, cleared: true };
189
+ }
190
+
191
+ return { start, complete, getAccessToken, logout, status };
192
+ }
package/lib/remote.js ADDED
@@ -0,0 +1,150 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export class RemoteError extends Error {
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ }
9
+ }
10
+
11
+ function apiBase(env) {
12
+ return String(env.GPDOC_API_BASE_URL || 'https://gpdoc.io').replace(/\/$/, '');
13
+ }
14
+
15
+ async function jsonRequest(fetchImpl, url, init, fallbackCode = 'REMOTE_REQUEST_FAILED') {
16
+ let response;
17
+ try {
18
+ response = await fetchImpl(url, init);
19
+ } catch {
20
+ throw new RemoteError(fallbackCode, 'GPDoc could not reach the remote provider.');
21
+ }
22
+ const payload = await response.json().catch(() => ({}));
23
+ if (!response.ok) {
24
+ const error = new RemoteError(payload.code || fallbackCode, payload.error || `Remote provider request failed (${response.status}).`);
25
+ error.status = response.status;
26
+ throw error;
27
+ }
28
+ return payload;
29
+ }
30
+
31
+ function bearer(token) {
32
+ return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
33
+ }
34
+
35
+ function mimeType(filePath) {
36
+ const extension = path.extname(filePath).toLowerCase();
37
+ if (extension === '.docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
38
+ if (extension === '.pdf') return 'application/pdf';
39
+ if (extension === '.html' || extension === '.htm') return 'text/html';
40
+ if (extension === '.json') return 'application/json';
41
+ return 'text/markdown';
42
+ }
43
+
44
+ function titleFrom(filePath, fallback = 'GPDoc Export') {
45
+ return path.basename(filePath).replace(/\.gpdoc\.md$/i, '').replace(/\.[^.]+$/, '') || fallback;
46
+ }
47
+
48
+ async function githubToken({ auth, env, fetchImpl }) {
49
+ const token = await auth.getAccessToken();
50
+ const payload = await jsonRequest(fetchImpl, `${apiBase(env)}/.netlify/functions/get-user-profile`, { headers: bearer(token) }, 'GITHUB_IDENTITY_REQUIRED');
51
+ if (typeof payload.github_access_token !== 'string' || !payload.github_access_token) {
52
+ throw new RemoteError('GITHUB_IDENTITY_REQUIRED', 'Connect GitHub to the signed-in GPDoc account before using GitHub sources.');
53
+ }
54
+ return payload.github_access_token;
55
+ }
56
+
57
+ async function githubEntitlement({ auth, env, fetchImpl }) {
58
+ const token = await auth.getAccessToken();
59
+ const payload = await jsonRequest(fetchImpl, `${apiBase(env)}/.netlify/functions/github-source-entitlement`, { headers: bearer(token) }, 'GITHUB_ENTITLEMENT_REQUIRED');
60
+ if (payload?.entitlement?.catalogMode !== 'authenticated') {
61
+ throw new RemoteError('GITHUB_ENTITLEMENT_REQUIRED', 'Private GitHub sources require an active GPDoc entitlement.');
62
+ }
63
+ }
64
+
65
+ function githubHeaders(token) {
66
+ return { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28' };
67
+ }
68
+
69
+ // @spec CLI-019, CLI-020, CLI-021, CLI-022, CLI-023, CLI-024, CLI-025
70
+ export function createRemoteClient({ auth, env = process.env, fetchImpl = globalThis.fetch } = {}) {
71
+ if (!auth) throw new Error('Remote client requires an auth manager.');
72
+ async function provider(functionName, body, method = 'POST') {
73
+ const token = await auth.getAccessToken();
74
+ return jsonRequest(fetchImpl, `${apiBase(env)}/.netlify/functions/${functionName}`, {
75
+ method, headers: bearer(token), ...(method === 'GET' ? {} : { body: JSON.stringify(body || {}) }),
76
+ });
77
+ }
78
+
79
+ return {
80
+ async googleSave({ filePath, title, mode = 'new', driveId, itemId, expectedRevision, filetype = 'document' }) {
81
+ await auth.getAccessToken();
82
+ const markdown = await readFile(filePath, 'utf8');
83
+ const payload = await provider('google-drive-source-save', {
84
+ title: title || titleFrom(filePath), markdown, mode, driveId, itemId, expectedRevision, filetype,
85
+ });
86
+ return { provider: 'google', mode, item: payload.item, warning: payload.warning || null };
87
+ },
88
+
89
+ async microsoftSave({ filePath, mode = 'new', driveId, itemId, filename }) {
90
+ await auth.getAccessToken();
91
+ const bytes = await readFile(filePath);
92
+ if (bytes.byteLength > 4 * 1024 * 1024) throw new RemoteError('MICROSOFT_FILE_TOO_LARGE', 'Microsoft files must be 4 MiB or smaller.');
93
+ const payload = await provider('microsoft-drive-export', {
94
+ mode, driveId, itemId, filename: filename || path.basename(filePath), mimeType: mimeType(filePath), bytes: bytes.toString('base64'),
95
+ });
96
+ return { provider: 'microsoft', mode, item: payload.item || payload };
97
+ },
98
+
99
+ async microsoftShare({ driveId, itemId, role, scope }) {
100
+ if (!driveId || !itemId) throw new RemoteError('USAGE', 'Microsoft sharing requires --drive-id and --item-id.');
101
+ if (!['view', 'edit'].includes(role)) throw new RemoteError('USAGE', 'Microsoft sharing requires --role view or edit.');
102
+ if (!['anonymous', 'organization'].includes(scope)) throw new RemoteError('USAGE', 'Microsoft sharing requires --scope anonymous or organization.');
103
+ const payload = await provider('microsoft-drive-share', { driveId, itemId, role, scope });
104
+ return { provider: 'microsoft', share: payload };
105
+ },
106
+
107
+ async gistCreate({ filePath, description, isPrivate = false }) {
108
+ const token = await githubToken({ auth, env, fetchImpl });
109
+ if (isPrivate) await githubEntitlement({ auth, env, fetchImpl });
110
+ const content = await readFile(filePath, 'utf8');
111
+ const payload = await jsonRequest(fetchImpl, 'https://api.github.com/gists', {
112
+ method: 'POST', headers: githubHeaders(token), body: JSON.stringify({ description: description || titleFrom(filePath), public: !isPrivate, files: { [path.basename(filePath)]: { content } } }),
113
+ }, 'GITHUB_REQUEST_FAILED');
114
+ return { provider: 'github', kind: 'gist', id: payload.id, url: payload.html_url, public: payload.public === true };
115
+ },
116
+
117
+ async gistUpdate({ gistId, filePath, description }) {
118
+ if (!gistId) throw new RemoteError('USAGE', 'Gist updates require a Gist ID.');
119
+ const token = await githubToken({ auth, env, fetchImpl });
120
+ const existing = await jsonRequest(fetchImpl, `https://api.github.com/gists/${encodeURIComponent(gistId)}`, { headers: githubHeaders(token) }, 'GITHUB_REQUEST_FAILED');
121
+ if (existing.public !== true) await githubEntitlement({ auth, env, fetchImpl });
122
+ const content = await readFile(filePath, 'utf8');
123
+ const payload = await jsonRequest(fetchImpl, `https://api.github.com/gists/${encodeURIComponent(gistId)}`, {
124
+ method: 'PATCH', headers: githubHeaders(token), body: JSON.stringify({ description: description || existing.description || titleFrom(filePath), files: { [path.basename(filePath)]: { content } } }),
125
+ }, 'GITHUB_REQUEST_FAILED');
126
+ return { provider: 'github', kind: 'gist', id: payload.id, url: payload.html_url, public: payload.public === true };
127
+ },
128
+
129
+ async repoPut({ repository, filePath, branch = 'main', remotePath, message }) {
130
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repository || '')) throw new RemoteError('USAGE', 'Repository must use OWNER/REPOSITORY format.');
131
+ if (!remotePath) throw new RemoteError('USAGE', 'Repository writes require --path.');
132
+ const token = await githubToken({ auth, env, fetchImpl });
133
+ const repo = await jsonRequest(fetchImpl, `https://api.github.com/repos/${repository}`, { headers: githubHeaders(token) }, 'GITHUB_REQUEST_FAILED');
134
+ if (repo.private === true) await githubEntitlement({ auth, env, fetchImpl });
135
+ const encodedPath = remotePath.split('/').map(encodeURIComponent).join('/');
136
+ let sha;
137
+ try {
138
+ const existing = await jsonRequest(fetchImpl, `https://api.github.com/repos/${repository}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`, { headers: githubHeaders(token) }, 'GITHUB_REQUEST_FAILED');
139
+ sha = existing.sha;
140
+ } catch (error) {
141
+ if (error?.status !== 404) throw error;
142
+ }
143
+ const content = await readFile(filePath);
144
+ const payload = await jsonRequest(fetchImpl, `https://api.github.com/repos/${repository}/contents/${encodedPath}`, {
145
+ method: 'PUT', headers: githubHeaders(token), body: JSON.stringify({ message: message || `Update ${remotePath} with GPDoc CLI`, content: content.toString('base64'), branch, ...(sha ? { sha } : {}) }),
146
+ }, 'GITHUB_REQUEST_FAILED');
147
+ return { provider: 'github', kind: 'repository-file', repository, path: remotePath, branch, url: payload.content?.html_url || null, commit: payload.commit?.sha || null };
148
+ },
149
+ };
150
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@gpdoc/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
7
7
  "files": [
8
8
  "bin",
9
+ "lib",
9
10
  "README.md"
10
11
  ],
11
12
  "publishConfig": {