gutterpress 0.10.4 → 0.10.5
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 +7 -3
- package/dist/api/index.d.ts +7 -3
- package/dist/api/index.js +24 -2
- package/dist/{audit-6gsbjcyp.js → audit-ax5ps29k.js} +4 -4
- package/dist/{build-8gx7gkgw.js → build-79dv44cn.js} +4 -4
- package/dist/{cli-7hay1r0h.js → cli-0t4zfevc.js} +1 -1
- package/dist/{cli-h617210r.js → cli-5pq315qe.js} +1 -1
- package/dist/{cli-zddg7r27.js → cli-chkwtg9m.js} +928 -74
- package/dist/{cli-d613m9n5.js → cli-v031r2h6.js} +1 -1
- package/dist/cli.js +14 -14
- package/dist/{doctor-mrqpw7g3.js → doctor-qejmbv32.js} +2 -2
- package/dist/{engine-dzrzyyew.js → engine-fwe8djyn.js} +1 -1
- package/dist/{engine-pta3yeyt.js → engine-mtyjc4v6.js} +2 -2
- package/dist/{index-cm4dywtk.js → index-ja0p4w5f.js} +1 -1
- package/dist/{index-p4yq14qw.js → index-k5hcp8wj.js} +1 -1
- package/dist/{index-1z8a090c.js → index-ya1yw5me.js} +972 -120
- package/dist/index.js +25 -3
- package/dist/lib/markdown/gutterpress-css.d.ts +10 -2
- package/dist/lib/open-path.d.ts +27 -0
- package/dist/lib/publish/connect-google.d.ts +34 -0
- package/dist/lib/publish/connect.d.ts +23 -0
- package/dist/lib/publish/google-auth.d.ts +104 -0
- package/dist/lib/publish/google-drive.d.ts +88 -0
- package/dist/lib/publish/google-errors.d.ts +49 -0
- package/dist/lib/publish/providers/gdrive.d.ts +25 -0
- package/dist/lib/publish/run-publish.d.ts +14 -1
- package/dist/lib/publish/types.d.ts +46 -2
- package/dist/lib/remote-auth/token-store.d.ts +1 -1
- package/dist/{lint-gmymvnm3.js → lint-wweg6pf0.js} +4 -4
- package/dist/{manifest.schema-084rxtwp.json → manifest.schema-hn6ac0ae.json} +25 -0
- package/dist/{new-hw7s7jne.js → new-yf8emyp3.js} +4 -4
- package/dist/{plugin-0qtfgjr2.js → plugin-dj6crat4.js} +4 -4
- package/dist/{preflight-j99dpj5e.js → preflight-d34pzgr6.js} +4 -4
- package/dist/{preview-sc3rcrkh.js → preview-g2f5982y.js} +4 -4
- package/dist/{publish-xwrwvrh0.js → publish-6vt6yqm4.js} +58 -9
- package/dist/schema/manifest.types.d.ts +20 -0
- package/dist/{validate-evzfg9m6.js → validate-vjwgxswp.js} +4 -4
- package/package.json +1 -1
|
@@ -13,12 +13,12 @@ import {
|
|
|
13
13
|
stampCreator,
|
|
14
14
|
stripAnnotations,
|
|
15
15
|
warn
|
|
16
|
-
} from "./cli-
|
|
16
|
+
} from "./cli-5pq315qe.js";
|
|
17
17
|
import {
|
|
18
18
|
DEBOUNCE,
|
|
19
19
|
UsageError,
|
|
20
20
|
resolvePort
|
|
21
|
-
} from "./cli-
|
|
21
|
+
} from "./cli-v031r2h6.js";
|
|
22
22
|
import {
|
|
23
23
|
MARGIN_BOX_IGNORED_PROPERTIES,
|
|
24
24
|
RENDER_TIMEOUT_MS,
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
getAssetPath,
|
|
28
28
|
getBrowser,
|
|
29
29
|
prewarmBrowser
|
|
30
|
-
} from "./cli-
|
|
30
|
+
} from "./cli-0t4zfevc.js";
|
|
31
31
|
import {
|
|
32
32
|
INSTALL_HINTS,
|
|
33
33
|
execCapture,
|
|
@@ -7537,7 +7537,7 @@ class PdfOutput {
|
|
|
7537
7537
|
const rawPdf = pdfxMode ? path8.join(stage, "raw.pdf") : path8.resolve(pdfFile);
|
|
7538
7538
|
await fsp2.mkdir(path8.dirname(path8.resolve(pdfFile)), { recursive: true });
|
|
7539
7539
|
log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
|
|
7540
|
-
const { buildNativePdf } = await import("./engine-
|
|
7540
|
+
const { buildNativePdf } = await import("./engine-fwe8djyn.js");
|
|
7541
7541
|
const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
|
|
7542
7542
|
title: config.title,
|
|
7543
7543
|
author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
|
|
@@ -7630,24 +7630,24 @@ async function runBuild(opts) {
|
|
|
7630
7630
|
|
|
7631
7631
|
// src/lib/open-path.ts
|
|
7632
7632
|
import { spawn } from "node:child_process";
|
|
7633
|
+
function buildOpenPathSpawnSpec(filePath, platform = process.platform) {
|
|
7634
|
+
switch (platform) {
|
|
7635
|
+
case "darwin":
|
|
7636
|
+
return { cmd: "open", args: [filePath], options: { detached: true, stdio: "ignore" } };
|
|
7637
|
+
case "win32":
|
|
7638
|
+
return {
|
|
7639
|
+
cmd: "cmd",
|
|
7640
|
+
args: ["/c", "start", '""', '"' + filePath + '"'],
|
|
7641
|
+
options: { detached: true, stdio: "ignore", windowsVerbatimArguments: true }
|
|
7642
|
+
};
|
|
7643
|
+
default:
|
|
7644
|
+
return { cmd: "xdg-open", args: [filePath], options: { detached: true, stdio: "ignore" } };
|
|
7645
|
+
}
|
|
7646
|
+
}
|
|
7633
7647
|
function openPath(filePath) {
|
|
7634
7648
|
return new Promise((resolve8, reject) => {
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
switch (process.platform) {
|
|
7638
|
-
case "darwin":
|
|
7639
|
-
cmd = "open";
|
|
7640
|
-
args = [filePath];
|
|
7641
|
-
break;
|
|
7642
|
-
case "win32":
|
|
7643
|
-
cmd = "cmd";
|
|
7644
|
-
args = ["/c", "start", "", filePath];
|
|
7645
|
-
break;
|
|
7646
|
-
default:
|
|
7647
|
-
cmd = "xdg-open";
|
|
7648
|
-
args = [filePath];
|
|
7649
|
-
}
|
|
7650
|
-
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
7649
|
+
const { cmd, args, options } = buildOpenPathSpawnSpec(filePath);
|
|
7650
|
+
const child = spawn(cmd, args, options);
|
|
7651
7651
|
child.once("error", reject);
|
|
7652
7652
|
child.once("spawn", () => {
|
|
7653
7653
|
child.unref();
|
|
@@ -10206,6 +10206,325 @@ async function listPublishAccounts(info2, deps) {
|
|
|
10206
10206
|
}));
|
|
10207
10207
|
}
|
|
10208
10208
|
|
|
10209
|
+
// src/lib/publish/google-auth.ts
|
|
10210
|
+
import crypto from "node:crypto";
|
|
10211
|
+
import http2 from "node:http";
|
|
10212
|
+
|
|
10213
|
+
// src/lib/publish/google-errors.ts
|
|
10214
|
+
function redactQueryCredentials(text) {
|
|
10215
|
+
return text.replace(/([?&](?:access_token|key)=)[^&\s"']+/gi, "$1(redacted)");
|
|
10216
|
+
}
|
|
10217
|
+
function parseGoogleApiError(status, body) {
|
|
10218
|
+
const err = body?.error;
|
|
10219
|
+
if (!err || typeof err !== "object")
|
|
10220
|
+
return { status };
|
|
10221
|
+
const e = err;
|
|
10222
|
+
const first = Array.isArray(e.errors) ? e.errors[0] : undefined;
|
|
10223
|
+
const reason = typeof first?.reason === "string" && first.reason ? first.reason : typeof e.status === "string" && e.status ? e.status : undefined;
|
|
10224
|
+
const message = typeof e.message === "string" && e.message.trim() ? redactQueryCredentials(e.message.trim()) : undefined;
|
|
10225
|
+
return { status, ...reason ? { reason } : {}, ...message ? { message } : {} };
|
|
10226
|
+
}
|
|
10227
|
+
async function readGoogleApiError(res) {
|
|
10228
|
+
const body = await res.json().catch(() => {
|
|
10229
|
+
return;
|
|
10230
|
+
});
|
|
10231
|
+
return parseGoogleApiError(res.status, body);
|
|
10232
|
+
}
|
|
10233
|
+
var RATE_LIMIT_REASONS = /^(dailyLimitExceeded|userRateLimitExceeded|rateLimitExceeded|quotaExceeded)$/i;
|
|
10234
|
+
var PERMISSION_REASONS = /^(insufficientPermissions|insufficientFilePermissions|forbidden|PERMISSION_DENIED)$/i;
|
|
10235
|
+
function hintFor(info2) {
|
|
10236
|
+
const reason = info2.reason ?? "";
|
|
10237
|
+
if (/^accessNotConfigured$/i.test(reason)) {
|
|
10238
|
+
return "Google Drive publishing isn't fully set up on this build: the Google Drive API isn't enabled for the app's Google Cloud project, so a maintainer needs to enable it (the link is in Google's message).";
|
|
10239
|
+
}
|
|
10240
|
+
if (/^storageQuotaExceeded$/i.test(reason)) {
|
|
10241
|
+
return "Your Google Drive is full — free up space (or choose a different folder) and try again.";
|
|
10242
|
+
}
|
|
10243
|
+
if (RATE_LIMIT_REASONS.test(reason)) {
|
|
10244
|
+
return "Google Drive is rate-limiting requests right now. Wait a minute and try again.";
|
|
10245
|
+
}
|
|
10246
|
+
if (PERMISSION_REASONS.test(reason) || info2.status === 401) {
|
|
10247
|
+
return "This Google Drive connection doesn't have permission for that — disconnect and connect Google Drive again to re-approve access.";
|
|
10248
|
+
}
|
|
10249
|
+
return;
|
|
10250
|
+
}
|
|
10251
|
+
function googleApiFailure(what, info2) {
|
|
10252
|
+
const parts = [`${what} (HTTP ${info2.status}${info2.reason ? `, ${info2.reason}` : ""}).`];
|
|
10253
|
+
const hint = hintFor(info2);
|
|
10254
|
+
if (hint)
|
|
10255
|
+
parts.push(hint);
|
|
10256
|
+
if (info2.message)
|
|
10257
|
+
parts.push(`Google said: "${info2.message}"`);
|
|
10258
|
+
let text = parts.join(" ");
|
|
10259
|
+
if (!/\bgoogle\b/i.test(text))
|
|
10260
|
+
text = `Google Drive: ${text}`;
|
|
10261
|
+
return new FriendlyHttpError(text);
|
|
10262
|
+
}
|
|
10263
|
+
|
|
10264
|
+
// src/lib/publish/google-auth.ts
|
|
10265
|
+
var GDRIVE_HOST = "gdrive";
|
|
10266
|
+
var GOOGLE_OAUTH_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
10267
|
+
var AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
10268
|
+
var TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
10269
|
+
var GOOGLE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
|
|
10270
|
+
var DRIVE_ABOUT_URL = "https://www.googleapis.com/drive/v3/about?fields=user(emailAddress)";
|
|
10271
|
+
var REQUEST_TIMEOUT_MS = 15000;
|
|
10272
|
+
var DEFAULT_FLOW_TIMEOUT_MS = 5 * 60000;
|
|
10273
|
+
var OFFLINE_MESSAGE = "Couldn't reach Google. Check your connection and try again.";
|
|
10274
|
+
var DEFAULT_GOOGLE_CLIENT_ID = "621278203862-2t35qditgvfh0pgosguqu2encf9c2h9j.apps.googleusercontent.com";
|
|
10275
|
+
var DEFAULT_GOOGLE_CLIENT_SECRET = "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";
|
|
10276
|
+
function resolveClientValue(explicit, env, embedded) {
|
|
10277
|
+
if (explicit !== undefined)
|
|
10278
|
+
return explicit.trim();
|
|
10279
|
+
if (env !== undefined)
|
|
10280
|
+
return env.trim();
|
|
10281
|
+
return embedded;
|
|
10282
|
+
}
|
|
10283
|
+
function resolveGoogleClientId(explicit) {
|
|
10284
|
+
return resolveClientValue(explicit, process.env.GUTTERPRESS_GOOGLE_CLIENT_ID, DEFAULT_GOOGLE_CLIENT_ID);
|
|
10285
|
+
}
|
|
10286
|
+
function resolveGoogleClientSecret(explicit) {
|
|
10287
|
+
return resolveClientValue(explicit, process.env.GUTTERPRESS_GOOGLE_CLIENT_SECRET, DEFAULT_GOOGLE_CLIENT_SECRET);
|
|
10288
|
+
}
|
|
10289
|
+
var GOOGLE_NOT_CONFIGURED_MESSAGE = "Google Drive publishing isn't configured on this build yet. Set GUTTERPRESS_GOOGLE_CLIENT_ID and GUTTERPRESS_GOOGLE_CLIENT_SECRET to enable it.";
|
|
10290
|
+
function requireGoogleClientCredentials(clientIdOpt, clientSecretOpt) {
|
|
10291
|
+
const clientId = resolveGoogleClientId(clientIdOpt);
|
|
10292
|
+
const clientSecret = resolveGoogleClientSecret(clientSecretOpt);
|
|
10293
|
+
if (!clientId || !clientSecret) {
|
|
10294
|
+
throw new Error(GOOGLE_NOT_CONFIGURED_MESSAGE);
|
|
10295
|
+
}
|
|
10296
|
+
return { clientId, clientSecret };
|
|
10297
|
+
}
|
|
10298
|
+
var RECONNECT_MESSAGE = "Your Google Drive connection expired or was revoked. Connect Google Drive again.";
|
|
10299
|
+
var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
10300
|
+
var DRIVE_PERMISSION_NOT_GRANTED_MESSAGE = "Google sign-in finished, but it didn't include the Google Drive permission, so Gutterpress can't create or see any files. Connect Google Drive again and allow it.";
|
|
10301
|
+
function grantedScopesInclude(scope, wanted) {
|
|
10302
|
+
if (scope === undefined)
|
|
10303
|
+
return true;
|
|
10304
|
+
return scope.split(/\s+/).includes(wanted);
|
|
10305
|
+
}
|
|
10306
|
+
function b64url(buf) {
|
|
10307
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
10308
|
+
}
|
|
10309
|
+
function pkceChallengeFromVerifier(verifier) {
|
|
10310
|
+
return b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
10311
|
+
}
|
|
10312
|
+
async function safeFetch(fetchImpl, url, init, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
10313
|
+
return withFetchTimeout({ timeoutMs, signal: init.signal ?? undefined, offlineMessage: OFFLINE_MESSAGE }, (signal) => fetchImpl(url, { ...init, signal }));
|
|
10314
|
+
}
|
|
10315
|
+
function friendlyTokenError(status, body) {
|
|
10316
|
+
const code = body?.error;
|
|
10317
|
+
if (code === "invalid_grant")
|
|
10318
|
+
return new Error(RECONNECT_MESSAGE);
|
|
10319
|
+
if (code === "invalid_client") {
|
|
10320
|
+
return new Error("Google rejected the app's sign-in credentials. This build's Google OAuth client id/secret may be misconfigured.");
|
|
10321
|
+
}
|
|
10322
|
+
return new FriendlyHttpError(`Google sign-in failed (HTTP ${status}${code ? `: ${code}` : ""}). Please try again.`);
|
|
10323
|
+
}
|
|
10324
|
+
var SUCCESS_HTML = `<html><body style="font:16px system-ui;padding:3rem"><h2>You're connected</h2><p>Return to Gutterpress — you can close this tab.</p></body></html>`;
|
|
10325
|
+
var FAILURE_HTML = `<html><body style="font:16px system-ui;padding:3rem"><h2>Sign-in didn't complete</h2><p>Return to Gutterpress and try again.</p></body></html>`;
|
|
10326
|
+
function waitForCallback(server, expectedState, redirectUri, timeoutMs, signal) {
|
|
10327
|
+
return new Promise((resolve8, reject) => {
|
|
10328
|
+
let settled = false;
|
|
10329
|
+
const cleanup = () => {
|
|
10330
|
+
clearTimeout(timer);
|
|
10331
|
+
server.removeListener("request", onRequest);
|
|
10332
|
+
signal?.removeEventListener("abort", onAbort);
|
|
10333
|
+
server.close();
|
|
10334
|
+
server.closeIdleConnections();
|
|
10335
|
+
};
|
|
10336
|
+
const finishResolve = (value) => {
|
|
10337
|
+
if (settled)
|
|
10338
|
+
return;
|
|
10339
|
+
settled = true;
|
|
10340
|
+
cleanup();
|
|
10341
|
+
resolve8(value);
|
|
10342
|
+
};
|
|
10343
|
+
const finishReject = (err) => {
|
|
10344
|
+
if (settled)
|
|
10345
|
+
return;
|
|
10346
|
+
settled = true;
|
|
10347
|
+
cleanup();
|
|
10348
|
+
reject(err);
|
|
10349
|
+
};
|
|
10350
|
+
const onRequest = (req, res) => {
|
|
10351
|
+
let url;
|
|
10352
|
+
try {
|
|
10353
|
+
url = new URL(req.url ?? "/", redirectUri);
|
|
10354
|
+
} catch {
|
|
10355
|
+
res.writeHead(400).end();
|
|
10356
|
+
return;
|
|
10357
|
+
}
|
|
10358
|
+
const err = url.searchParams.get("error");
|
|
10359
|
+
const gotState = url.searchParams.get("state");
|
|
10360
|
+
const code = url.searchParams.get("code");
|
|
10361
|
+
if (!err && !code) {
|
|
10362
|
+
res.writeHead(204, { Connection: "close" }).end();
|
|
10363
|
+
return;
|
|
10364
|
+
}
|
|
10365
|
+
const fail = (message) => {
|
|
10366
|
+
res.writeHead(200, { "Content-Type": "text/html", Connection: "close" }).end(FAILURE_HTML);
|
|
10367
|
+
finishReject(new Error(message));
|
|
10368
|
+
};
|
|
10369
|
+
if (err) {
|
|
10370
|
+
fail("Google sign-in was declined. You can connect Google Drive again whenever you're ready.");
|
|
10371
|
+
return;
|
|
10372
|
+
}
|
|
10373
|
+
if (gotState !== expectedState) {
|
|
10374
|
+
fail("Google sign-in failed a security check (state mismatch). Connect Google Drive again.");
|
|
10375
|
+
return;
|
|
10376
|
+
}
|
|
10377
|
+
res.writeHead(200, { "Content-Type": "text/html", Connection: "close" }).end(SUCCESS_HTML);
|
|
10378
|
+
finishResolve({ code });
|
|
10379
|
+
};
|
|
10380
|
+
const onAbort = () => finishReject(new Error("Google sign-in was canceled."));
|
|
10381
|
+
const timer = setTimeout(() => finishReject(new Error("Google sign-in timed out waiting for the browser. Try again, or use GDRIVE_REFRESH_TOKEN for headless/CI use.")), timeoutMs);
|
|
10382
|
+
server.on("request", onRequest);
|
|
10383
|
+
if (signal) {
|
|
10384
|
+
if (signal.aborted)
|
|
10385
|
+
return onAbort();
|
|
10386
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
10387
|
+
}
|
|
10388
|
+
});
|
|
10389
|
+
}
|
|
10390
|
+
|
|
10391
|
+
class GoogleAuthProvider {
|
|
10392
|
+
clientIdOpt;
|
|
10393
|
+
clientSecretOpt;
|
|
10394
|
+
fetchImpl;
|
|
10395
|
+
openBrowser;
|
|
10396
|
+
timeoutMs;
|
|
10397
|
+
bindPort;
|
|
10398
|
+
constructor(options = {}) {
|
|
10399
|
+
this.clientIdOpt = options.clientId;
|
|
10400
|
+
this.clientSecretOpt = options.clientSecret;
|
|
10401
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
10402
|
+
this.openBrowser = options.openBrowser ?? openPath;
|
|
10403
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_FLOW_TIMEOUT_MS;
|
|
10404
|
+
this.bindPort = options.port ?? 0;
|
|
10405
|
+
}
|
|
10406
|
+
async connect(callbacks) {
|
|
10407
|
+
const { clientId, clientSecret } = requireGoogleClientCredentials(this.clientIdOpt, this.clientSecretOpt);
|
|
10408
|
+
const { signal } = callbacks;
|
|
10409
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
10410
|
+
const state = b64url(crypto.randomBytes(16));
|
|
10411
|
+
const challenge = pkceChallengeFromVerifier(verifier);
|
|
10412
|
+
const server = http2.createServer();
|
|
10413
|
+
await new Promise((resolve8, reject) => {
|
|
10414
|
+
const onListenError = (err) => {
|
|
10415
|
+
reject(new Error(`Couldn't start the local sign-in listener: ${err.message}`));
|
|
10416
|
+
};
|
|
10417
|
+
server.once("error", onListenError);
|
|
10418
|
+
server.listen(this.bindPort, "127.0.0.1", () => {
|
|
10419
|
+
server.removeListener("error", onListenError);
|
|
10420
|
+
resolve8();
|
|
10421
|
+
});
|
|
10422
|
+
});
|
|
10423
|
+
const address = server.address();
|
|
10424
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
10425
|
+
const redirectUri = `http://127.0.0.1:${port}`;
|
|
10426
|
+
const authUrl = `${AUTHORIZATION_ENDPOINT}?` + new URLSearchParams({
|
|
10427
|
+
client_id: clientId,
|
|
10428
|
+
redirect_uri: redirectUri,
|
|
10429
|
+
response_type: "code",
|
|
10430
|
+
scope: GOOGLE_OAUTH_SCOPE,
|
|
10431
|
+
code_challenge: challenge,
|
|
10432
|
+
code_challenge_method: "S256",
|
|
10433
|
+
state,
|
|
10434
|
+
access_type: "offline",
|
|
10435
|
+
prompt: "consent"
|
|
10436
|
+
}).toString();
|
|
10437
|
+
callbacks.onAuthUrl(authUrl);
|
|
10438
|
+
this.openBrowser(authUrl).catch(() => {});
|
|
10439
|
+
const { code } = await waitForCallback(server, state, redirectUri, this.timeoutMs, signal);
|
|
10440
|
+
const tokenRes = await safeFetch(this.fetchImpl, TOKEN_ENDPOINT, {
|
|
10441
|
+
method: "POST",
|
|
10442
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
10443
|
+
body: new URLSearchParams({
|
|
10444
|
+
grant_type: "authorization_code",
|
|
10445
|
+
code,
|
|
10446
|
+
client_id: clientId,
|
|
10447
|
+
client_secret: clientSecret,
|
|
10448
|
+
redirect_uri: redirectUri,
|
|
10449
|
+
code_verifier: verifier
|
|
10450
|
+
}).toString(),
|
|
10451
|
+
signal
|
|
10452
|
+
});
|
|
10453
|
+
const tokenBody = await tokenRes.json().catch(() => ({}));
|
|
10454
|
+
if (!tokenRes.ok || !tokenBody.access_token) {
|
|
10455
|
+
throw friendlyTokenError(tokenRes.status, tokenBody);
|
|
10456
|
+
}
|
|
10457
|
+
if (!tokenBody.refresh_token) {
|
|
10458
|
+
throw new Error("Google didn't return a refresh token. Try connecting again — if this keeps happening, revoke Gutterpress's access at myaccount.google.com/permissions and reconnect.");
|
|
10459
|
+
}
|
|
10460
|
+
if (!grantedScopesInclude(tokenBody.scope, DRIVE_FILE_SCOPE)) {
|
|
10461
|
+
throw new Error(DRIVE_PERMISSION_NOT_GRANTED_MESSAGE);
|
|
10462
|
+
}
|
|
10463
|
+
const email = await this.fetchEmail(tokenBody.access_token);
|
|
10464
|
+
return {
|
|
10465
|
+
host: GDRIVE_HOST,
|
|
10466
|
+
kind: "google-oauth",
|
|
10467
|
+
token: tokenBody.refresh_token,
|
|
10468
|
+
...email ? { username: email, label: `Google Drive — ${email}` } : { label: "Google Drive" },
|
|
10469
|
+
createdAt: Date.now()
|
|
10470
|
+
};
|
|
10471
|
+
}
|
|
10472
|
+
async fetchEmail(accessToken) {
|
|
10473
|
+
let res;
|
|
10474
|
+
try {
|
|
10475
|
+
res = await safeFetch(this.fetchImpl, DRIVE_ABOUT_URL, {
|
|
10476
|
+
method: "GET",
|
|
10477
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
10478
|
+
});
|
|
10479
|
+
} catch {
|
|
10480
|
+
return;
|
|
10481
|
+
}
|
|
10482
|
+
if (res.status === 401 || res.status === 403) {
|
|
10483
|
+
throw googleApiFailure("Google Drive rejected the new connection", await readGoogleApiError(res));
|
|
10484
|
+
}
|
|
10485
|
+
if (!res.ok)
|
|
10486
|
+
return;
|
|
10487
|
+
try {
|
|
10488
|
+
const body = await res.json();
|
|
10489
|
+
return body.user?.emailAddress;
|
|
10490
|
+
} catch {
|
|
10491
|
+
return;
|
|
10492
|
+
}
|
|
10493
|
+
}
|
|
10494
|
+
}
|
|
10495
|
+
async function revokeGoogleCredential(refreshToken, options = {}) {
|
|
10496
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
10497
|
+
try {
|
|
10498
|
+
await withFetchTimeout({ timeoutMs: 1e4, offlineMessage: OFFLINE_MESSAGE }, (signal) => fetchImpl(GOOGLE_REVOKE_ENDPOINT, {
|
|
10499
|
+
method: "POST",
|
|
10500
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
10501
|
+
body: new URLSearchParams({ token: refreshToken }).toString(),
|
|
10502
|
+
signal
|
|
10503
|
+
}));
|
|
10504
|
+
} catch {}
|
|
10505
|
+
}
|
|
10506
|
+
|
|
10507
|
+
// src/lib/publish/connect-google.ts
|
|
10508
|
+
async function connectGoogleDrive(options, deps, callbacks) {
|
|
10509
|
+
const provider = new GoogleAuthProvider({
|
|
10510
|
+
clientId: options.clientId,
|
|
10511
|
+
clientSecret: options.clientSecret,
|
|
10512
|
+
fetchImpl: deps.fetch,
|
|
10513
|
+
...options.openBrowser ? { openBrowser: options.openBrowser } : {}
|
|
10514
|
+
});
|
|
10515
|
+
const credential = await provider.connect(callbacks);
|
|
10516
|
+
const email = credential.username;
|
|
10517
|
+
const account = (options.account ?? "").trim();
|
|
10518
|
+
const key = publishCredentialKey(GDRIVE_HOST, account);
|
|
10519
|
+
const { username: _accountEmailAsUsername, ...credentialWithoutUsername } = credential;
|
|
10520
|
+
await deps.tokenStore.set(key, {
|
|
10521
|
+
...credentialWithoutUsername,
|
|
10522
|
+
...account ? { username: account } : {},
|
|
10523
|
+
label: account ? `${account} (${email ?? "Google Drive"})` : credential.label
|
|
10524
|
+
});
|
|
10525
|
+
return { connected: true, ...email ? { email } : {} };
|
|
10526
|
+
}
|
|
10527
|
+
|
|
10209
10528
|
// src/lib/publish/command-runner.ts
|
|
10210
10529
|
var CAPTURE_LIMIT = 64 * 1024;
|
|
10211
10530
|
var PUBLISH_IDLE_TIMEOUT_MS = 300000;
|
|
@@ -10433,9 +10752,521 @@ var drivethrurpgProvider = {
|
|
|
10433
10752
|
}
|
|
10434
10753
|
};
|
|
10435
10754
|
|
|
10436
|
-
// src/lib/publish/
|
|
10437
|
-
import {
|
|
10755
|
+
// src/lib/publish/providers/gdrive.ts
|
|
10756
|
+
import { mkdtemp as mkdtemp3, readFile as readFile25, readdir as readdir6, rm as rm4, stat as stat6, writeFile as writeFile7 } from "node:fs/promises";
|
|
10757
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
10438
10758
|
import path18 from "node:path";
|
|
10759
|
+
import { Zip, ZipDeflate } from "fflate";
|
|
10760
|
+
|
|
10761
|
+
// src/lib/publish/google-drive.ts
|
|
10762
|
+
import { open as open3 } from "node:fs/promises";
|
|
10763
|
+
var TOKEN_ENDPOINT2 = "https://oauth2.googleapis.com/token";
|
|
10764
|
+
var DRIVE_API_BASE = "https://www.googleapis.com/drive/v3";
|
|
10765
|
+
var DRIVE_UPLOAD_BASE = "https://www.googleapis.com/upload/drive/v3/files";
|
|
10766
|
+
var METADATA_TIMEOUT_MS2 = 30000;
|
|
10767
|
+
var TOKEN_REFRESH_TIMEOUT_MS = 15000;
|
|
10768
|
+
var CHUNK_TIMEOUT_MS = 120000;
|
|
10769
|
+
var OFFLINE_MESSAGE2 = "Couldn't reach Google Drive. Check your connection and try again.";
|
|
10770
|
+
async function driveFetch(fetchImpl, url, init, timeoutMs = METADATA_TIMEOUT_MS2) {
|
|
10771
|
+
return withFetchTimeout({ timeoutMs, offlineMessage: OFFLINE_MESSAGE2 }, (signal) => fetchImpl(url, { ...init, signal }));
|
|
10772
|
+
}
|
|
10773
|
+
function authHeaders(accessToken) {
|
|
10774
|
+
return { Authorization: `Bearer ${accessToken}` };
|
|
10775
|
+
}
|
|
10776
|
+
async function driveFailure(res, what) {
|
|
10777
|
+
return googleApiFailure(what, await readGoogleApiError(res));
|
|
10778
|
+
}
|
|
10779
|
+
function escapeDriveQueryValue(value) {
|
|
10780
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
10781
|
+
}
|
|
10782
|
+
async function refreshAccessToken(fetchImpl, params) {
|
|
10783
|
+
const res = await driveFetch(fetchImpl, TOKEN_ENDPOINT2, {
|
|
10784
|
+
method: "POST",
|
|
10785
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
10786
|
+
body: new URLSearchParams({
|
|
10787
|
+
grant_type: "refresh_token",
|
|
10788
|
+
refresh_token: params.refreshToken,
|
|
10789
|
+
client_id: params.clientId,
|
|
10790
|
+
client_secret: params.clientSecret
|
|
10791
|
+
}).toString()
|
|
10792
|
+
}, TOKEN_REFRESH_TIMEOUT_MS);
|
|
10793
|
+
const body = await res.json().catch(() => ({}));
|
|
10794
|
+
if (!res.ok || !body.access_token) {
|
|
10795
|
+
if (body.error === "invalid_grant")
|
|
10796
|
+
throw new Error(RECONNECT_MESSAGE);
|
|
10797
|
+
throw new FriendlyHttpError(`Google rejected the Drive connection (HTTP ${res.status}${body.error ? `: ${body.error}` : ""}). ${RECONNECT_MESSAGE}`);
|
|
10798
|
+
}
|
|
10799
|
+
return { accessToken: body.access_token, expiresIn: body.expires_in ?? 3600 };
|
|
10800
|
+
}
|
|
10801
|
+
async function driveAbout(fetchImpl, accessToken) {
|
|
10802
|
+
const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/about?fields=user(emailAddress),storageQuota`, {
|
|
10803
|
+
method: "GET",
|
|
10804
|
+
headers: authHeaders(accessToken)
|
|
10805
|
+
});
|
|
10806
|
+
if (!res.ok)
|
|
10807
|
+
throw await driveFailure(res, "Couldn't read your Google Drive account info");
|
|
10808
|
+
const body = await res.json();
|
|
10809
|
+
const limit = body.storageQuota?.limit != null ? Number(body.storageQuota.limit) : null;
|
|
10810
|
+
const usage = Number(body.storageQuota?.usage ?? 0);
|
|
10811
|
+
return {
|
|
10812
|
+
email: body.user?.emailAddress,
|
|
10813
|
+
quota: {
|
|
10814
|
+
limitBytes: limit,
|
|
10815
|
+
usageBytes: usage,
|
|
10816
|
+
freeBytes: limit != null ? Math.max(0, limit - usage) : null
|
|
10817
|
+
}
|
|
10818
|
+
};
|
|
10819
|
+
}
|
|
10820
|
+
var FOLDER_MIME = "application/vnd.google-apps.folder";
|
|
10821
|
+
async function listFolders(fetchImpl, accessToken) {
|
|
10822
|
+
const q = `mimeType='${FOLDER_MIME}' and trashed=false`;
|
|
10823
|
+
const all = [];
|
|
10824
|
+
let pageToken;
|
|
10825
|
+
do {
|
|
10826
|
+
const params = new URLSearchParams({
|
|
10827
|
+
q,
|
|
10828
|
+
fields: "nextPageToken,files(id,name)",
|
|
10829
|
+
orderBy: "modifiedTime desc",
|
|
10830
|
+
pageSize: "100"
|
|
10831
|
+
});
|
|
10832
|
+
if (pageToken)
|
|
10833
|
+
params.set("pageToken", pageToken);
|
|
10834
|
+
const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files?${params.toString()}`, {
|
|
10835
|
+
method: "GET",
|
|
10836
|
+
headers: authHeaders(accessToken)
|
|
10837
|
+
});
|
|
10838
|
+
if (!res.ok)
|
|
10839
|
+
throw await driveFailure(res, "Couldn't list Google Drive folders");
|
|
10840
|
+
const body = await res.json();
|
|
10841
|
+
all.push(...body.files ?? []);
|
|
10842
|
+
pageToken = body.nextPageToken;
|
|
10843
|
+
} while (pageToken);
|
|
10844
|
+
return all;
|
|
10845
|
+
}
|
|
10846
|
+
async function getFolderById(fetchImpl, accessToken, folderId) {
|
|
10847
|
+
const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files/${encodeURIComponent(folderId)}?fields=id,name,trashed,mimeType`, {
|
|
10848
|
+
method: "GET",
|
|
10849
|
+
headers: authHeaders(accessToken)
|
|
10850
|
+
});
|
|
10851
|
+
if (res.status === 404)
|
|
10852
|
+
return null;
|
|
10853
|
+
if (!res.ok)
|
|
10854
|
+
throw await driveFailure(res, "Couldn't look up the Google Drive folder");
|
|
10855
|
+
const body = await res.json();
|
|
10856
|
+
if (body.trashed || body.mimeType !== FOLDER_MIME)
|
|
10857
|
+
return null;
|
|
10858
|
+
return { id: body.id, name: body.name };
|
|
10859
|
+
}
|
|
10860
|
+
async function createFolder(fetchImpl, accessToken, name) {
|
|
10861
|
+
const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files?fields=id,name`, {
|
|
10862
|
+
method: "POST",
|
|
10863
|
+
headers: { ...authHeaders(accessToken), "Content-Type": "application/json" },
|
|
10864
|
+
body: JSON.stringify({ name, mimeType: FOLDER_MIME })
|
|
10865
|
+
});
|
|
10866
|
+
if (!res.ok)
|
|
10867
|
+
throw await driveFailure(res, `Couldn't create the Google Drive folder "${name}"`);
|
|
10868
|
+
return await res.json();
|
|
10869
|
+
}
|
|
10870
|
+
async function findFolderByName(fetchImpl, accessToken, name) {
|
|
10871
|
+
const q = `name='${escapeDriveQueryValue(name)}' and mimeType='${FOLDER_MIME}' and trashed=false`;
|
|
10872
|
+
const params = new URLSearchParams({
|
|
10873
|
+
q,
|
|
10874
|
+
fields: "files(id,name)",
|
|
10875
|
+
orderBy: "modifiedTime desc",
|
|
10876
|
+
pageSize: "1"
|
|
10877
|
+
});
|
|
10878
|
+
const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files?${params.toString()}`, {
|
|
10879
|
+
method: "GET",
|
|
10880
|
+
headers: authHeaders(accessToken)
|
|
10881
|
+
});
|
|
10882
|
+
if (!res.ok)
|
|
10883
|
+
throw await driveFailure(res, `Couldn't look up the Google Drive folder "${name}"`);
|
|
10884
|
+
const body = await res.json();
|
|
10885
|
+
return body.files?.[0] ?? null;
|
|
10886
|
+
}
|
|
10887
|
+
async function ensureFolder(fetchImpl, accessToken, name) {
|
|
10888
|
+
const found = await findFolderByName(fetchImpl, accessToken, name);
|
|
10889
|
+
if (found)
|
|
10890
|
+
return found;
|
|
10891
|
+
return createFolder(fetchImpl, accessToken, name);
|
|
10892
|
+
}
|
|
10893
|
+
async function findFileInFolder(fetchImpl, accessToken, folderId, name) {
|
|
10894
|
+
const q = `name='${escapeDriveQueryValue(name)}' and '${escapeDriveQueryValue(folderId)}' in parents and trashed=false`;
|
|
10895
|
+
const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files?q=${encodeURIComponent(q)}&fields=files(id,name,webViewLink)&pageSize=1`, { method: "GET", headers: authHeaders(accessToken) });
|
|
10896
|
+
if (!res.ok)
|
|
10897
|
+
throw await driveFailure(res, "Couldn't search the Google Drive folder");
|
|
10898
|
+
const body = await res.json();
|
|
10899
|
+
return body.files?.[0] ?? null;
|
|
10900
|
+
}
|
|
10901
|
+
var RESUMABLE_CHUNK_SIZE = 8 * 1024 * 1024;
|
|
10902
|
+
var MAX_CHUNK_RETRIES = 3;
|
|
10903
|
+
var RETRY_BASE_DELAY_MS = 500;
|
|
10904
|
+
function defaultSleep(ms) {
|
|
10905
|
+
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
10906
|
+
}
|
|
10907
|
+
async function startResumableSession(fetchImpl, accessToken, opts) {
|
|
10908
|
+
const mimeType = opts.mimeType ?? "application/pdf";
|
|
10909
|
+
const url = opts.fileId ? `${DRIVE_UPLOAD_BASE}/${encodeURIComponent(opts.fileId)}?uploadType=resumable&fields=id,name,webViewLink` : `${DRIVE_UPLOAD_BASE}?uploadType=resumable&fields=id,name,webViewLink`;
|
|
10910
|
+
const res = await driveFetch(fetchImpl, url, {
|
|
10911
|
+
method: opts.fileId ? "PATCH" : "POST",
|
|
10912
|
+
headers: {
|
|
10913
|
+
...authHeaders(accessToken),
|
|
10914
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
10915
|
+
"X-Upload-Content-Type": mimeType,
|
|
10916
|
+
"X-Upload-Content-Length": String(opts.totalBytes)
|
|
10917
|
+
},
|
|
10918
|
+
body: JSON.stringify(opts.fileId ? {} : { name: opts.name, parents: opts.parentFolderId ? [opts.parentFolderId] : undefined })
|
|
10919
|
+
});
|
|
10920
|
+
const session = res.headers.get("location");
|
|
10921
|
+
if (!res.ok || !session)
|
|
10922
|
+
throw await driveFailure(res, "Couldn't start the Google Drive upload");
|
|
10923
|
+
return session;
|
|
10924
|
+
}
|
|
10925
|
+
async function queryUploadStatus(fetchImpl, sessionUrl, total) {
|
|
10926
|
+
let res;
|
|
10927
|
+
try {
|
|
10928
|
+
res = await driveFetch(fetchImpl, sessionUrl, { method: "PUT", headers: { "Content-Range": `bytes */${total}` } }, CHUNK_TIMEOUT_MS);
|
|
10929
|
+
} catch {
|
|
10930
|
+
return null;
|
|
10931
|
+
}
|
|
10932
|
+
if (res.status === 308) {
|
|
10933
|
+
const range = res.headers.get("range");
|
|
10934
|
+
return { kind: "progress", receivedBytes: range ? Number(range.split("-")[1]) + 1 : 0 };
|
|
10935
|
+
}
|
|
10936
|
+
if (res.status === 200 || res.status === 201) {
|
|
10937
|
+
return { kind: "done", file: await res.json() };
|
|
10938
|
+
}
|
|
10939
|
+
return null;
|
|
10940
|
+
}
|
|
10941
|
+
async function putChunkWithRetry(fetchImpl, sessionUrl, buf, start, total, maxRetries, sleep) {
|
|
10942
|
+
let attempt = 0;
|
|
10943
|
+
for (;; ) {
|
|
10944
|
+
let res;
|
|
10945
|
+
try {
|
|
10946
|
+
res = await driveFetch(fetchImpl, sessionUrl, {
|
|
10947
|
+
method: "PUT",
|
|
10948
|
+
headers: {
|
|
10949
|
+
"Content-Range": `bytes ${start}-${start + buf.length - 1}/${total}`,
|
|
10950
|
+
"Content-Length": String(buf.length)
|
|
10951
|
+
},
|
|
10952
|
+
body: buf
|
|
10953
|
+
}, CHUNK_TIMEOUT_MS);
|
|
10954
|
+
} catch (e) {
|
|
10955
|
+
if (attempt >= maxRetries)
|
|
10956
|
+
throw e;
|
|
10957
|
+
attempt++;
|
|
10958
|
+
const status = await queryUploadStatus(fetchImpl, sessionUrl, total);
|
|
10959
|
+
if (status?.kind === "done")
|
|
10960
|
+
return { done: true, file: status.file };
|
|
10961
|
+
if (status?.kind === "progress" && status.receivedBytes > start) {
|
|
10962
|
+
const chunkEnd = start + buf.length;
|
|
10963
|
+
if (status.receivedBytes >= chunkEnd) {
|
|
10964
|
+
return { done: false, nextOffset: status.receivedBytes };
|
|
10965
|
+
}
|
|
10966
|
+
buf = buf.subarray(status.receivedBytes - start);
|
|
10967
|
+
start = status.receivedBytes;
|
|
10968
|
+
}
|
|
10969
|
+
await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
|
|
10970
|
+
continue;
|
|
10971
|
+
}
|
|
10972
|
+
if (res.status === 308) {
|
|
10973
|
+
const range = res.headers.get("range");
|
|
10974
|
+
const nextOffset = range ? Number(range.split("-")[1]) + 1 : start + buf.length;
|
|
10975
|
+
return { done: false, nextOffset };
|
|
10976
|
+
}
|
|
10977
|
+
if (res.status === 200 || res.status === 201) {
|
|
10978
|
+
return { done: true, file: await res.json() };
|
|
10979
|
+
}
|
|
10980
|
+
if ((res.status === 429 || res.status >= 500) && attempt < maxRetries) {
|
|
10981
|
+
attempt++;
|
|
10982
|
+
const retryAfter = res.headers.get("retry-after");
|
|
10983
|
+
const delayMs = retryAfter ? Number(retryAfter) * 1000 : RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
10984
|
+
await sleep(Number.isFinite(delayMs) && delayMs > 0 ? delayMs : RETRY_BASE_DELAY_MS);
|
|
10985
|
+
continue;
|
|
10986
|
+
}
|
|
10987
|
+
throw await driveFailure(res, "Google Drive upload failed");
|
|
10988
|
+
}
|
|
10989
|
+
}
|
|
10990
|
+
async function resumableUpload(fetchImpl, accessToken, opts) {
|
|
10991
|
+
const chunkSize = opts.chunkSize ?? RESUMABLE_CHUNK_SIZE;
|
|
10992
|
+
const maxRetries = opts.maxRetriesPerChunk ?? MAX_CHUNK_RETRIES;
|
|
10993
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
10994
|
+
const sessionUrl = await startResumableSession(fetchImpl, accessToken, opts);
|
|
10995
|
+
const fh = await open3(opts.filePath, "r");
|
|
10996
|
+
try {
|
|
10997
|
+
let offset = 0;
|
|
10998
|
+
for (;; ) {
|
|
10999
|
+
const len = Math.min(chunkSize, opts.totalBytes - offset);
|
|
11000
|
+
const buf = Buffer.allocUnsafe(len);
|
|
11001
|
+
const { bytesRead } = await fh.read(buf, 0, len, offset);
|
|
11002
|
+
if (bytesRead !== len) {
|
|
11003
|
+
throw new FriendlyHttpError(`Couldn't read "${opts.filePath}" while uploading to Google Drive: expected ${len} bytes at offset ${offset} but only read ${bytesRead}. The file may have changed on disk during publish.`);
|
|
11004
|
+
}
|
|
11005
|
+
const result = await putChunkWithRetry(fetchImpl, sessionUrl, buf, offset, opts.totalBytes, maxRetries, sleep);
|
|
11006
|
+
if (result.done) {
|
|
11007
|
+
opts.onProgress?.(opts.totalBytes, opts.totalBytes);
|
|
11008
|
+
return result.file;
|
|
11009
|
+
}
|
|
11010
|
+
offset = result.nextOffset;
|
|
11011
|
+
opts.onProgress?.(Math.min(offset, opts.totalBytes), opts.totalBytes);
|
|
11012
|
+
if (offset >= opts.totalBytes) {
|
|
11013
|
+
throw new FriendlyHttpError("Google Drive accepted every byte but never confirmed the upload. Try publishing again.");
|
|
11014
|
+
}
|
|
11015
|
+
}
|
|
11016
|
+
} finally {
|
|
11017
|
+
await fh.close();
|
|
11018
|
+
}
|
|
11019
|
+
}
|
|
11020
|
+
|
|
11021
|
+
// src/lib/publish/providers/gdrive.ts
|
|
11022
|
+
var DEFAULT_FOLDER_NAME = "Gutterpress";
|
|
11023
|
+
var NOT_CONNECTED_MESSAGE = "Google Drive isn't connected. Run `gutterpress publish --provider gdrive --connect` (or set GDRIVE_REFRESH_TOKEN) first.";
|
|
11024
|
+
var info4 = {
|
|
11025
|
+
id: "gdrive",
|
|
11026
|
+
label: "Google Drive",
|
|
11027
|
+
kind: "api",
|
|
11028
|
+
format: "pdf",
|
|
11029
|
+
formats: ["pdf", "html"],
|
|
11030
|
+
description: "Upload the finished PDF (or the website export, zipped) to a folder in your Google Drive — publishing again updates the same file, so shared links stay current. Drive delivers files, not live websites; use Azure Static Web Apps to publish the HTML export as a site.",
|
|
11031
|
+
configFields: [{ key: "folder", label: "Drive folder", placeholder: DEFAULT_FOLDER_NAME }],
|
|
11032
|
+
credential: {
|
|
11033
|
+
required: true,
|
|
11034
|
+
host: GDRIVE_HOST,
|
|
11035
|
+
envVar: "GDRIVE_REFRESH_TOKEN",
|
|
11036
|
+
connect: "oauth",
|
|
11037
|
+
hint: "Click Connect Google Drive and approve in your browser — nothing to paste."
|
|
11038
|
+
},
|
|
11039
|
+
destinations: { label: "Folder", canCreate: true }
|
|
11040
|
+
};
|
|
11041
|
+
function readConfig(req) {
|
|
11042
|
+
const cfg = req.config;
|
|
11043
|
+
return {
|
|
11044
|
+
folder: cfg.folder?.trim() || undefined,
|
|
11045
|
+
folderId: cfg.folderId?.trim() || undefined
|
|
11046
|
+
};
|
|
11047
|
+
}
|
|
11048
|
+
function formatBytes(bytes) {
|
|
11049
|
+
if (bytes < 1024 * 1024)
|
|
11050
|
+
return `${(bytes / 1024).toFixed(0)} KB`;
|
|
11051
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
11052
|
+
}
|
|
11053
|
+
async function mintAccessToken(resolved, fetchImpl) {
|
|
11054
|
+
const { clientId, clientSecret } = requireGoogleClientCredentials();
|
|
11055
|
+
const { accessToken } = await refreshAccessToken(fetchImpl, {
|
|
11056
|
+
clientId,
|
|
11057
|
+
clientSecret,
|
|
11058
|
+
refreshToken: resolved.credential.token
|
|
11059
|
+
});
|
|
11060
|
+
return accessToken;
|
|
11061
|
+
}
|
|
11062
|
+
async function getAccessToken(req) {
|
|
11063
|
+
const resolved = await resolvePublishCredential(info4, req.deps);
|
|
11064
|
+
if (!resolved)
|
|
11065
|
+
throw new Error(NOT_CONNECTED_MESSAGE);
|
|
11066
|
+
const accessToken = await mintAccessToken(resolved, req.deps.fetch ?? globalThis.fetch);
|
|
11067
|
+
return { accessToken, source: resolved.source };
|
|
11068
|
+
}
|
|
11069
|
+
async function resolveFolder(fetchImpl, accessToken, cfg) {
|
|
11070
|
+
if (cfg.folderId) {
|
|
11071
|
+
const folder = await getFolderById(fetchImpl, accessToken, cfg.folderId);
|
|
11072
|
+
if (!folder) {
|
|
11073
|
+
throw new Error(`The Drive folder recorded in publish.gdrive.folderId ("${cfg.folderId}") can't be found — it may have been trashed or the id copied wrong. Pick the folder again.`);
|
|
11074
|
+
}
|
|
11075
|
+
return folder;
|
|
11076
|
+
}
|
|
11077
|
+
return ensureFolder(fetchImpl, accessToken, cfg.folder || DEFAULT_FOLDER_NAME);
|
|
11078
|
+
}
|
|
11079
|
+
function folderUrl(id) {
|
|
11080
|
+
return `https://drive.google.com/drive/folders/${id}`;
|
|
11081
|
+
}
|
|
11082
|
+
function toDestination(folder) {
|
|
11083
|
+
return { id: folder.id, title: folder.name, url: folderUrl(folder.id) };
|
|
11084
|
+
}
|
|
11085
|
+
var ZIP_CHUNK_BYTES = 64 * 1024;
|
|
11086
|
+
function yieldToEventLoop() {
|
|
11087
|
+
return new Promise((resolve8) => setImmediate(resolve8));
|
|
11088
|
+
}
|
|
11089
|
+
function zipEntriesNonBlocking(entries, opts) {
|
|
11090
|
+
return new Promise((resolve8, reject) => {
|
|
11091
|
+
const chunks = [];
|
|
11092
|
+
let total = 0;
|
|
11093
|
+
let settled = false;
|
|
11094
|
+
const archive = new Zip((err, chunk2, final) => {
|
|
11095
|
+
if (settled)
|
|
11096
|
+
return;
|
|
11097
|
+
if (err) {
|
|
11098
|
+
settled = true;
|
|
11099
|
+
reject(err);
|
|
11100
|
+
return;
|
|
11101
|
+
}
|
|
11102
|
+
if (chunk2) {
|
|
11103
|
+
chunks.push(chunk2);
|
|
11104
|
+
total += chunk2.length;
|
|
11105
|
+
}
|
|
11106
|
+
if (final) {
|
|
11107
|
+
settled = true;
|
|
11108
|
+
const out = new Uint8Array(total);
|
|
11109
|
+
let offset = 0;
|
|
11110
|
+
for (const c of chunks) {
|
|
11111
|
+
out.set(c, offset);
|
|
11112
|
+
offset += c.length;
|
|
11113
|
+
}
|
|
11114
|
+
resolve8(out);
|
|
11115
|
+
}
|
|
11116
|
+
});
|
|
11117
|
+
(async () => {
|
|
11118
|
+
for (const [name, data] of Object.entries(entries)) {
|
|
11119
|
+
const stream = new ZipDeflate(name, opts);
|
|
11120
|
+
archive.add(stream);
|
|
11121
|
+
if (data.length === 0) {
|
|
11122
|
+
stream.push(new Uint8Array(0), true);
|
|
11123
|
+
continue;
|
|
11124
|
+
}
|
|
11125
|
+
for (let offset = 0;offset < data.length; offset += ZIP_CHUNK_BYTES) {
|
|
11126
|
+
const end = Math.min(offset + ZIP_CHUNK_BYTES, data.length);
|
|
11127
|
+
stream.push(data.subarray(offset, end), end >= data.length);
|
|
11128
|
+
await yieldToEventLoop();
|
|
11129
|
+
}
|
|
11130
|
+
}
|
|
11131
|
+
archive.end();
|
|
11132
|
+
})().catch((e) => {
|
|
11133
|
+
if (!settled) {
|
|
11134
|
+
settled = true;
|
|
11135
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
11136
|
+
}
|
|
11137
|
+
});
|
|
11138
|
+
});
|
|
11139
|
+
}
|
|
11140
|
+
async function collectZipEntries(root, dir, out, onWarn) {
|
|
11141
|
+
const entries = await readdir6(dir, { withFileTypes: true });
|
|
11142
|
+
for (const entry of entries) {
|
|
11143
|
+
const full = path18.join(dir, entry.name);
|
|
11144
|
+
if (entry.isDirectory()) {
|
|
11145
|
+
await collectZipEntries(root, full, out, onWarn);
|
|
11146
|
+
} else if (entry.isFile()) {
|
|
11147
|
+
const rel = path18.relative(root, full).split(path18.sep).join("/");
|
|
11148
|
+
out[rel] = await readFile25(full);
|
|
11149
|
+
} else if (entry.isSymbolicLink()) {
|
|
11150
|
+
const resolved = await stat6(full).catch(() => null);
|
|
11151
|
+
if (resolved?.isFile()) {
|
|
11152
|
+
const rel = path18.relative(root, full).split(path18.sep).join("/");
|
|
11153
|
+
out[rel] = await readFile25(full);
|
|
11154
|
+
} else if (resolved?.isDirectory()) {
|
|
11155
|
+
await collectZipEntries(root, full, out, onWarn);
|
|
11156
|
+
} else {
|
|
11157
|
+
onWarn?.(`Skipped "${path18.relative(root, full)}" in the website export — it's a broken symlink.`);
|
|
11158
|
+
}
|
|
11159
|
+
} else {
|
|
11160
|
+
onWarn?.(`Skipped "${path18.relative(root, full)}" in the website export — not a file, folder, or symlink.`);
|
|
11161
|
+
}
|
|
11162
|
+
}
|
|
11163
|
+
}
|
|
11164
|
+
async function zipHtmlExport(exportDir, title, onWarn, writeArchiveImpl = writeFile7) {
|
|
11165
|
+
const entries = {};
|
|
11166
|
+
await collectZipEntries(exportDir, exportDir, entries, onWarn);
|
|
11167
|
+
const fileName = `${bookSlug(title)}-website.zip`;
|
|
11168
|
+
const tmpRoot = await mkdtemp3(path18.join(tmpdir3(), "gutterpress-gdrive-"));
|
|
11169
|
+
try {
|
|
11170
|
+
const filePath = path18.join(tmpRoot, fileName);
|
|
11171
|
+
const zipped = await zipEntriesNonBlocking(entries, { level: 6 });
|
|
11172
|
+
await writeArchiveImpl(filePath, zipped);
|
|
11173
|
+
return {
|
|
11174
|
+
filePath,
|
|
11175
|
+
fileName,
|
|
11176
|
+
mimeType: "application/zip",
|
|
11177
|
+
cleanup: () => rm4(tmpRoot, { recursive: true, force: true })
|
|
11178
|
+
};
|
|
11179
|
+
} catch (e) {
|
|
11180
|
+
await rm4(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
|
11181
|
+
throw e;
|
|
11182
|
+
}
|
|
11183
|
+
}
|
|
11184
|
+
var gdriveProvider = {
|
|
11185
|
+
info: info4,
|
|
11186
|
+
async authenticate(req) {
|
|
11187
|
+
const resolved = await resolvePublishCredential(info4, req.deps);
|
|
11188
|
+
if (!resolved)
|
|
11189
|
+
return { ok: false, message: NOT_CONNECTED_MESSAGE };
|
|
11190
|
+
try {
|
|
11191
|
+
const accessToken = await mintAccessToken(resolved, req.deps.fetch ?? globalThis.fetch);
|
|
11192
|
+
await driveAbout(req.deps.fetch ?? globalThis.fetch, accessToken);
|
|
11193
|
+
return { ok: true, source: resolved.source };
|
|
11194
|
+
} catch (e) {
|
|
11195
|
+
return { ok: false, source: resolved.source, message: e instanceof Error ? e.message : String(e) };
|
|
11196
|
+
}
|
|
11197
|
+
},
|
|
11198
|
+
async preflight(_req) {
|
|
11199
|
+
return [];
|
|
11200
|
+
},
|
|
11201
|
+
async listDestinations(req) {
|
|
11202
|
+
const { accessToken } = await getAccessToken(req);
|
|
11203
|
+
const folders = await listFolders(req.deps.fetch ?? globalThis.fetch, accessToken);
|
|
11204
|
+
return folders.map(toDestination);
|
|
11205
|
+
},
|
|
11206
|
+
async createDestination(req, name) {
|
|
11207
|
+
const { accessToken } = await getAccessToken(req);
|
|
11208
|
+
const folder = await createFolder(req.deps.fetch ?? globalThis.fetch, accessToken, name);
|
|
11209
|
+
return toDestination(folder);
|
|
11210
|
+
},
|
|
11211
|
+
async upload(req) {
|
|
11212
|
+
const fetchImpl = req.deps.fetch ?? globalThis.fetch;
|
|
11213
|
+
const cfg = readConfig(req);
|
|
11214
|
+
const { accessToken } = await getAccessToken(req);
|
|
11215
|
+
const isHtml = req.artifact.format === "html";
|
|
11216
|
+
const source = isHtml ? await zipHtmlExport(req.artifact.path, req.project.title, (msg) => req.deps.onProgress?.(msg)) : {
|
|
11217
|
+
filePath: req.artifact.path,
|
|
11218
|
+
fileName: path18.basename(req.artifact.path),
|
|
11219
|
+
mimeType: "application/pdf",
|
|
11220
|
+
cleanup: async () => {}
|
|
11221
|
+
};
|
|
11222
|
+
try {
|
|
11223
|
+
const artifactStat = await stat6(source.filePath);
|
|
11224
|
+
const about = await driveAbout(fetchImpl, accessToken);
|
|
11225
|
+
if (about.quota.limitBytes != null && about.quota.freeBytes != null) {
|
|
11226
|
+
if (artifactStat.size > about.quota.freeBytes) {
|
|
11227
|
+
throw new Error(`Your Google Drive is full — this ${isHtml ? "website export" : "PDF"} needs ${formatBytes(artifactStat.size)} but only ${formatBytes(about.quota.freeBytes)} is free.`);
|
|
11228
|
+
}
|
|
11229
|
+
}
|
|
11230
|
+
req.deps.onProgress?.("Resolving the Drive folder…");
|
|
11231
|
+
const folder = await resolveFolder(fetchImpl, accessToken, cfg);
|
|
11232
|
+
const fileName = source.fileName;
|
|
11233
|
+
const existing = await findFileInFolder(fetchImpl, accessToken, folder.id, fileName);
|
|
11234
|
+
req.deps.onProgress?.(existing ? `Updating "${fileName}" in "${folder.name}"…` : `Uploading "${fileName}" to "${folder.name}"…`);
|
|
11235
|
+
let lastReported = -1;
|
|
11236
|
+
const file = await resumableUpload(fetchImpl, accessToken, {
|
|
11237
|
+
...existing ? { fileId: existing.id } : {},
|
|
11238
|
+
name: fileName,
|
|
11239
|
+
parentFolderId: folder.id,
|
|
11240
|
+
filePath: source.filePath,
|
|
11241
|
+
totalBytes: artifactStat.size,
|
|
11242
|
+
mimeType: source.mimeType,
|
|
11243
|
+
onProgress: (uploaded, total) => {
|
|
11244
|
+
const pct = total > 0 ? Math.floor(uploaded / total * 100) : 100;
|
|
11245
|
+
if (pct !== lastReported) {
|
|
11246
|
+
lastReported = pct;
|
|
11247
|
+
req.deps.onProgress?.(`Uploaded ${formatBytes(uploaded)} of ${formatBytes(total)} (${pct}%)…`);
|
|
11248
|
+
}
|
|
11249
|
+
}
|
|
11250
|
+
});
|
|
11251
|
+
return {
|
|
11252
|
+
kind: "published",
|
|
11253
|
+
url: file.webViewLink,
|
|
11254
|
+
detail: `Uploaded "${fileName}" to the "${folder.name}" folder in your Google Drive${existing ? " (updated the existing file)" : ""}.`,
|
|
11255
|
+
followUp: [
|
|
11256
|
+
"To share it, open it in Drive and use the Share button — Gutterpress never changes who can see your files.",
|
|
11257
|
+
isHtml ? "Google Drive stores files, not live websites — download and unzip it to view the export, or publish it as a real site with the Azure Static Web Apps provider." : undefined,
|
|
11258
|
+
cfg.folderId ? undefined : `Tip (CLI): record the folder id in the manifest (publish.gdrive.folderId: "${folder.id}") so renaming the folder in Drive can never break publishing.`
|
|
11259
|
+
].filter((s) => !!s)
|
|
11260
|
+
};
|
|
11261
|
+
} finally {
|
|
11262
|
+
await source.cleanup();
|
|
11263
|
+
}
|
|
11264
|
+
}
|
|
11265
|
+
};
|
|
11266
|
+
|
|
11267
|
+
// src/lib/publish/butler.ts
|
|
11268
|
+
import { chmod, mkdir as mkdir9, stat as stat7, writeFile as writeFile8 } from "node:fs/promises";
|
|
11269
|
+
import path19 from "node:path";
|
|
10439
11270
|
import { unzipSync } from "fflate";
|
|
10440
11271
|
var BUTLER_DOWNLOAD_TIMEOUT_MS = 300000;
|
|
10441
11272
|
function butlerBrothChannel(platform = process.platform, arch = process.arch) {
|
|
@@ -10455,7 +11286,7 @@ function butlerBinaryName(platform = process.platform) {
|
|
|
10455
11286
|
}
|
|
10456
11287
|
async function fileExists2(p) {
|
|
10457
11288
|
try {
|
|
10458
|
-
return (await
|
|
11289
|
+
return (await stat7(p)).isFile();
|
|
10459
11290
|
} catch {
|
|
10460
11291
|
return false;
|
|
10461
11292
|
}
|
|
@@ -10472,8 +11303,8 @@ async function ensureButler(deps) {
|
|
|
10472
11303
|
}
|
|
10473
11304
|
if (await commandExists("butler", runCommand, deps.env))
|
|
10474
11305
|
return "butler";
|
|
10475
|
-
const cacheDir =
|
|
10476
|
-
const cached =
|
|
11306
|
+
const cacheDir = path19.join(deps.configDir ?? defaultConfigDir(), "tools", "butler");
|
|
11307
|
+
const cached = path19.join(cacheDir, butlerBinaryName());
|
|
10477
11308
|
if (await fileExists2(cached))
|
|
10478
11309
|
return cached;
|
|
10479
11310
|
const channel = butlerBrothChannel();
|
|
@@ -10500,9 +11331,9 @@ async function ensureButler(deps) {
|
|
|
10500
11331
|
for (const [name, data] of Object.entries(files)) {
|
|
10501
11332
|
if (name.endsWith("/"))
|
|
10502
11333
|
continue;
|
|
10503
|
-
const target =
|
|
10504
|
-
await
|
|
10505
|
-
if (
|
|
11334
|
+
const target = path19.join(cacheDir, path19.basename(name));
|
|
11335
|
+
await writeFile8(target, data);
|
|
11336
|
+
if (path19.basename(name) === butlerBinaryName()) {
|
|
10506
11337
|
await chmod(target, 493).catch(() => {});
|
|
10507
11338
|
foundBinary = true;
|
|
10508
11339
|
}
|
|
@@ -10518,7 +11349,7 @@ async function ensureButler(deps) {
|
|
|
10518
11349
|
var ITCH_HOST = "itch.io";
|
|
10519
11350
|
var TARGET_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
10520
11351
|
var ITCH_API_TIMEOUT_MS = 15000;
|
|
10521
|
-
var
|
|
11352
|
+
var info5 = {
|
|
10522
11353
|
id: "itch",
|
|
10523
11354
|
label: "itch.io",
|
|
10524
11355
|
kind: "api",
|
|
@@ -10536,7 +11367,7 @@ var info4 = {
|
|
|
10536
11367
|
hint: "Create an API key under itch.io → Settings → API keys, then paste it here."
|
|
10537
11368
|
}
|
|
10538
11369
|
};
|
|
10539
|
-
function
|
|
11370
|
+
function readConfig2(req) {
|
|
10540
11371
|
const cfg = req.config;
|
|
10541
11372
|
return {
|
|
10542
11373
|
target: (cfg.target ?? "").trim(),
|
|
@@ -10548,9 +11379,9 @@ function itchProjectUrl(target) {
|
|
|
10548
11379
|
return `https://${user}.itch.io/${game}`;
|
|
10549
11380
|
}
|
|
10550
11381
|
var itchProvider = {
|
|
10551
|
-
info:
|
|
11382
|
+
info: info5,
|
|
10552
11383
|
async authenticate(req) {
|
|
10553
|
-
const resolved = await resolvePublishCredential(
|
|
11384
|
+
const resolved = await resolvePublishCredential(info5, req.deps);
|
|
10554
11385
|
if (!resolved) {
|
|
10555
11386
|
return {
|
|
10556
11387
|
ok: false,
|
|
@@ -10593,7 +11424,7 @@ var itchProvider = {
|
|
|
10593
11424
|
},
|
|
10594
11425
|
async preflight(req) {
|
|
10595
11426
|
const issues = [];
|
|
10596
|
-
const { target, channel } =
|
|
11427
|
+
const { target, channel } = readConfig2(req);
|
|
10597
11428
|
if (!target) {
|
|
10598
11429
|
issues.push({
|
|
10599
11430
|
severity: "error",
|
|
@@ -10617,8 +11448,8 @@ var itchProvider = {
|
|
|
10617
11448
|
return issues;
|
|
10618
11449
|
},
|
|
10619
11450
|
async upload(req) {
|
|
10620
|
-
const { target, channel } =
|
|
10621
|
-
const resolved = await resolvePublishCredential(
|
|
11451
|
+
const { target, channel } = readConfig2(req);
|
|
11452
|
+
const resolved = await resolvePublishCredential(info5, req.deps);
|
|
10622
11453
|
if (!resolved) {
|
|
10623
11454
|
throw new Error("No itch.io API key found. Connect itch.io (or set BUTLER_API_KEY) first.");
|
|
10624
11455
|
}
|
|
@@ -10646,7 +11477,7 @@ ${tail}` : ""}`);
|
|
|
10646
11477
|
|
|
10647
11478
|
// src/lib/publish/providers/kdp.ts
|
|
10648
11479
|
var KDP_URL = "https://kdp.amazon.com/en_US/bookshelf";
|
|
10649
|
-
var
|
|
11480
|
+
var info6 = {
|
|
10650
11481
|
id: "kdp",
|
|
10651
11482
|
label: "Amazon KDP",
|
|
10652
11483
|
kind: "guided",
|
|
@@ -10656,7 +11487,7 @@ var info5 = {
|
|
|
10656
11487
|
credential: { required: false, host: "kdp.amazon.com" }
|
|
10657
11488
|
};
|
|
10658
11489
|
var kdpProvider = {
|
|
10659
|
-
info:
|
|
11490
|
+
info: info6,
|
|
10660
11491
|
async authenticate() {
|
|
10661
11492
|
return { ok: true };
|
|
10662
11493
|
},
|
|
@@ -10711,7 +11542,7 @@ function requireValidShop(shop) {
|
|
|
10711
11542
|
}
|
|
10712
11543
|
return shop;
|
|
10713
11544
|
}
|
|
10714
|
-
var
|
|
11545
|
+
var info7 = {
|
|
10715
11546
|
id: "shopify",
|
|
10716
11547
|
label: "Shopify",
|
|
10717
11548
|
kind: "api",
|
|
@@ -10734,7 +11565,7 @@ var info6 = {
|
|
|
10734
11565
|
hint: "Create a custom app in Shopify admin (Settings → Apps → Develop apps) with write_products scope, then paste its Admin API access token."
|
|
10735
11566
|
}
|
|
10736
11567
|
};
|
|
10737
|
-
function
|
|
11568
|
+
function readConfig3(req) {
|
|
10738
11569
|
const cfg = req.config;
|
|
10739
11570
|
return {
|
|
10740
11571
|
shop: (cfg.shop ?? "").trim().toLowerCase(),
|
|
@@ -10743,9 +11574,9 @@ function readConfig2(req) {
|
|
|
10743
11574
|
};
|
|
10744
11575
|
}
|
|
10745
11576
|
async function adminGraphQL(req, query, variables = {}) {
|
|
10746
|
-
const { shop: rawShop, apiVersion } =
|
|
11577
|
+
const { shop: rawShop, apiVersion } = readConfig3(req);
|
|
10747
11578
|
const shop = requireValidShop(rawShop);
|
|
10748
|
-
const resolved = await resolvePublishCredential(
|
|
11579
|
+
const resolved = await resolvePublishCredential(info7, req.deps);
|
|
10749
11580
|
if (!resolved) {
|
|
10750
11581
|
throw new Error("No Shopify access token found. Connect Shopify (or set SHOPIFY_ADMIN_TOKEN) first.");
|
|
10751
11582
|
}
|
|
@@ -10796,9 +11627,9 @@ function userErrorsToMessage(result) {
|
|
|
10796
11627
|
return errs?.length ? errs[0].message : null;
|
|
10797
11628
|
}
|
|
10798
11629
|
var shopifyProvider = {
|
|
10799
|
-
info:
|
|
11630
|
+
info: info7,
|
|
10800
11631
|
async authenticate(req) {
|
|
10801
|
-
const resolved = await resolvePublishCredential(
|
|
11632
|
+
const resolved = await resolvePublishCredential(info7, req.deps);
|
|
10802
11633
|
if (!resolved) {
|
|
10803
11634
|
return {
|
|
10804
11635
|
ok: false,
|
|
@@ -10818,7 +11649,7 @@ var shopifyProvider = {
|
|
|
10818
11649
|
},
|
|
10819
11650
|
async preflight(req) {
|
|
10820
11651
|
const issues = [];
|
|
10821
|
-
const { shop } =
|
|
11652
|
+
const { shop } = readConfig3(req);
|
|
10822
11653
|
if (!shop) {
|
|
10823
11654
|
issues.push({
|
|
10824
11655
|
severity: "error",
|
|
@@ -10842,13 +11673,13 @@ var shopifyProvider = {
|
|
|
10842
11673
|
return issues;
|
|
10843
11674
|
},
|
|
10844
11675
|
async listProducts(req) {
|
|
10845
|
-
const { shop } =
|
|
11676
|
+
const { shop } = readConfig3(req);
|
|
10846
11677
|
const data = await adminGraphQL(req, `{ products(first: 25, sortKey: UPDATED_AT, reverse: true) { nodes { ${PRODUCT_FIELDS} } } }`);
|
|
10847
11678
|
const nodes = data.products?.nodes ?? [];
|
|
10848
11679
|
return nodes.map((n) => toProduct(shop, n));
|
|
10849
11680
|
},
|
|
10850
11681
|
async updateListing(req, productId, metadata) {
|
|
10851
|
-
const { shop } =
|
|
11682
|
+
const { shop } = readConfig3(req);
|
|
10852
11683
|
const data = await adminGraphQL(req, `mutation($product: ProductUpdateInput!) {
|
|
10853
11684
|
productUpdate(product: $product) {
|
|
10854
11685
|
product { ${PRODUCT_FIELDS} }
|
|
@@ -10869,7 +11700,7 @@ var shopifyProvider = {
|
|
|
10869
11700
|
return toProduct(shop, result.product);
|
|
10870
11701
|
},
|
|
10871
11702
|
async upload(req) {
|
|
10872
|
-
const { shop, productId } =
|
|
11703
|
+
const { shop, productId } = readConfig3(req);
|
|
10873
11704
|
const title = req.project.title || "Untitled";
|
|
10874
11705
|
let product;
|
|
10875
11706
|
if (productId) {
|
|
@@ -10915,7 +11746,8 @@ var PROVIDERS = {
|
|
|
10915
11746
|
drivethrurpg: drivethrurpgProvider,
|
|
10916
11747
|
kdp: kdpProvider,
|
|
10917
11748
|
"azure-swa": azureSwaProvider,
|
|
10918
|
-
shopify: shopifyProvider
|
|
11749
|
+
shopify: shopifyProvider,
|
|
11750
|
+
gdrive: gdriveProvider
|
|
10919
11751
|
};
|
|
10920
11752
|
function listPublishProviders() {
|
|
10921
11753
|
return Object.values(PROVIDERS).map((p) => p.info);
|
|
@@ -10930,8 +11762,17 @@ function publishProviderFor(id) {
|
|
|
10930
11762
|
}
|
|
10931
11763
|
|
|
10932
11764
|
// src/lib/publish/run-publish.ts
|
|
10933
|
-
import { readdir as
|
|
10934
|
-
import
|
|
11765
|
+
import { readdir as readdir7, stat as stat8 } from "node:fs/promises";
|
|
11766
|
+
import path20 from "node:path";
|
|
11767
|
+
function resolvePublishFormat(info8, providerConfig) {
|
|
11768
|
+
if (info8.formats && info8.formats.length > 0) {
|
|
11769
|
+
const configured = typeof providerConfig.format === "string" ? providerConfig.format.trim() : "";
|
|
11770
|
+
if (configured && info8.formats.includes(configured)) {
|
|
11771
|
+
return configured;
|
|
11772
|
+
}
|
|
11773
|
+
}
|
|
11774
|
+
return info8.format;
|
|
11775
|
+
}
|
|
10935
11776
|
async function resolvePublishRequest(options, deps) {
|
|
10936
11777
|
const provider = publishProviderFor(options.providerId);
|
|
10937
11778
|
const { manifest, manifestDir } = await loadManifestWithPath(options.manifestPath ?? options.projectDir, { explicit: options.manifestPath !== undefined });
|
|
@@ -10942,10 +11783,11 @@ async function resolvePublishRequest(options, deps) {
|
|
|
10942
11783
|
const effectiveAccount = bookAccount || deps.credentialAccount;
|
|
10943
11784
|
const effectiveDeps = effectiveAccount ? { ...deps, credentialAccount: effectiveAccount } : deps;
|
|
10944
11785
|
const outDir = resolveOutputDir(manifestDir, config.title);
|
|
10945
|
-
const
|
|
11786
|
+
const effectiveFormat = resolvePublishFormat(provider.info, providerConfig);
|
|
11787
|
+
const defaultArtifact = effectiveFormat === "pdf" ? path20.join(outDir, artifactName(config.title, "pdf")) : outDir;
|
|
10946
11788
|
const artifact = {
|
|
10947
|
-
path: options.artifactPath ?
|
|
10948
|
-
format:
|
|
11789
|
+
path: options.artifactPath ? path20.resolve(options.projectDir, options.artifactPath) : defaultArtifact,
|
|
11790
|
+
format: effectiveFormat
|
|
10949
11791
|
};
|
|
10950
11792
|
return {
|
|
10951
11793
|
project: {
|
|
@@ -10962,7 +11804,7 @@ var PDF_HINT = "Build the PDF first (gutterpress build, or export it from the ap
|
|
|
10962
11804
|
var HTML_HINT = "Build the website export first (gutterpress build --format html).";
|
|
10963
11805
|
async function artifactIssues(artifact) {
|
|
10964
11806
|
try {
|
|
10965
|
-
const s = await
|
|
11807
|
+
const s = await stat8(artifact.path);
|
|
10966
11808
|
if (artifact.format === "pdf" && !s.isFile()) {
|
|
10967
11809
|
return [
|
|
10968
11810
|
{
|
|
@@ -11008,7 +11850,7 @@ async function artifactIssues(artifact) {
|
|
|
11008
11850
|
var BUILD_FINGERPRINT_FILENAME = "build-fingerprint.json";
|
|
11009
11851
|
async function htmlDirIssues(dir) {
|
|
11010
11852
|
const issues = [];
|
|
11011
|
-
const isThere = async (rel) =>
|
|
11853
|
+
const isThere = async (rel) => stat8(path20.join(dir, rel)).then(() => true, () => false);
|
|
11012
11854
|
if (!await isThere(BOOK_HTML)) {
|
|
11013
11855
|
issues.push({
|
|
11014
11856
|
severity: "error",
|
|
@@ -11016,13 +11858,13 @@ async function htmlDirIssues(dir) {
|
|
|
11016
11858
|
message: `${dir} has no ${BOOK_HTML} — it isn't an HTML export. ${HTML_HINT}`
|
|
11017
11859
|
});
|
|
11018
11860
|
}
|
|
11019
|
-
const entries = await
|
|
11861
|
+
const entries = await readdir7(dir).catch(() => []);
|
|
11020
11862
|
const extras = entries.filter((name) => name.toLowerCase().endsWith(".pdf") || name === BUILD_FINGERPRINT_FILENAME);
|
|
11021
11863
|
if (extras.length > 0) {
|
|
11022
11864
|
issues.push({
|
|
11023
11865
|
severity: "warning",
|
|
11024
11866
|
id: "publish/html-dir-extras",
|
|
11025
|
-
message: `${dir} also contains ${extras.join(" and ")} — everything in the folder
|
|
11867
|
+
message: `${dir} also contains ${extras.join(" and ")} — everything in the folder gets bundled and published along with the website. ` + "Use a dedicated output folder for the website (gutterpress build --format html --out <dir>) if that isn't intended."
|
|
11026
11868
|
});
|
|
11027
11869
|
}
|
|
11028
11870
|
return issues;
|
|
@@ -11079,14 +11921,17 @@ function overlayStore(inner, key, candidate) {
|
|
|
11079
11921
|
}
|
|
11080
11922
|
async function connectPublishProvider(options, deps) {
|
|
11081
11923
|
const provider = publishProviderFor(options.providerId);
|
|
11082
|
-
const
|
|
11083
|
-
if (!
|
|
11084
|
-
throw new Error(`${
|
|
11924
|
+
const info8 = provider.info;
|
|
11925
|
+
if (!info8.credential.required) {
|
|
11926
|
+
throw new Error(`${info8.label} needs no API key — just publish when you're ready.`);
|
|
11927
|
+
}
|
|
11928
|
+
if (info8.credential.connect === "oauth") {
|
|
11929
|
+
throw new Error(`${info8.label} connects through your browser, not a pasted key — run ` + `"gutterpress publish --provider ${info8.id} --connect" or use the desktop app's Connect button.`);
|
|
11085
11930
|
}
|
|
11086
11931
|
const token = options.token.trim();
|
|
11087
11932
|
if (!token)
|
|
11088
11933
|
throw new Error("Paste an API key first.");
|
|
11089
|
-
const host =
|
|
11934
|
+
const host = info8.credential.host;
|
|
11090
11935
|
const account = (options.account ?? "").trim();
|
|
11091
11936
|
const key = publishCredentialKey(host, account);
|
|
11092
11937
|
const candidate = {
|
|
@@ -11094,12 +11939,12 @@ async function connectPublishProvider(options, deps) {
|
|
|
11094
11939
|
kind: "token",
|
|
11095
11940
|
token,
|
|
11096
11941
|
...account ? { username: account } : {},
|
|
11097
|
-
label: account ||
|
|
11942
|
+
label: account || info8.label,
|
|
11098
11943
|
createdAt: Date.now()
|
|
11099
11944
|
};
|
|
11100
11945
|
const req = await resolvePublishRequest({
|
|
11101
11946
|
projectDir: options.projectDir,
|
|
11102
|
-
providerId:
|
|
11947
|
+
providerId: info8.id,
|
|
11103
11948
|
...options.manifestPath ? { manifestPath: options.manifestPath } : {}
|
|
11104
11949
|
}, deps);
|
|
11105
11950
|
const trialDeps = {
|
|
@@ -11110,10 +11955,19 @@ async function connectPublishProvider(options, deps) {
|
|
|
11110
11955
|
};
|
|
11111
11956
|
const auth = await provider.authenticate({ ...req, deps: trialDeps });
|
|
11112
11957
|
if (!auth.ok) {
|
|
11113
|
-
throw new Error(auth.message ?? `${
|
|
11958
|
+
throw new Error(auth.message ?? `${info8.label} didn't accept that key.`);
|
|
11114
11959
|
}
|
|
11115
11960
|
await deps.tokenStore.set(key, candidate);
|
|
11116
|
-
return { connected: true, providerId:
|
|
11961
|
+
return { connected: true, providerId: info8.id };
|
|
11962
|
+
}
|
|
11963
|
+
async function disconnectPublishCredential(key, deps, options = {}) {
|
|
11964
|
+
const existing = await deps.tokenStore.get(key);
|
|
11965
|
+
await deps.tokenStore.delete(key);
|
|
11966
|
+
if (existing?.kind === "google-oauth") {
|
|
11967
|
+
const revoke = revokeGoogleCredential(existing.token, { fetchImpl: deps.fetch });
|
|
11968
|
+
if (options.awaitRevoke)
|
|
11969
|
+
await revoke;
|
|
11970
|
+
}
|
|
11117
11971
|
}
|
|
11118
11972
|
// src/lib/theme-manager.ts
|
|
11119
11973
|
import { isSeq as isSeq4, Scalar as Scalar2 } from "yaml";
|
|
@@ -11169,7 +12023,7 @@ function isSmallBody(body) {
|
|
|
11169
12023
|
function gitTimeoutError(what, ms) {
|
|
11170
12024
|
return new Error(`Git network operation timed out after ${Math.round(ms / 1000)}s (${what}); ` + `couldn't reach the remote (ETIMEDOUT).`);
|
|
11171
12025
|
}
|
|
11172
|
-
function withIdleTimeout(
|
|
12026
|
+
function withIdleTimeout(http3, idleMs = GIT_HTTP_IDLE_TIMEOUT_MS, uploadMs = GIT_HTTP_UPLOAD_TIMEOUT_MS) {
|
|
11173
12027
|
return {
|
|
11174
12028
|
async request(options) {
|
|
11175
12029
|
let timer;
|
|
@@ -11198,7 +12052,7 @@ function withIdleTimeout(http2, idleMs = GIT_HTTP_IDLE_TIMEOUT_MS, uploadMs = GI
|
|
|
11198
12052
|
arm(isLargeUpload ? uploadMs : idleMs, isLargeUpload ? "the upload did not complete" : "the remote did not respond");
|
|
11199
12053
|
let res;
|
|
11200
12054
|
try {
|
|
11201
|
-
res = await race(
|
|
12055
|
+
res = await race(http3.request(options));
|
|
11202
12056
|
} finally {
|
|
11203
12057
|
disarm();
|
|
11204
12058
|
}
|
|
@@ -11252,17 +12106,17 @@ import git6 from "isomorphic-git";
|
|
|
11252
12106
|
import git5 from "isomorphic-git";
|
|
11253
12107
|
import diff3Merge from "diff3";
|
|
11254
12108
|
// src/lib/publish/selections.ts
|
|
11255
|
-
import { chmod as chmod2, mkdir as mkdir10, readFile as
|
|
11256
|
-
import
|
|
12109
|
+
import { chmod as chmod2, mkdir as mkdir10, readFile as readFile26, writeFile as writeFile9 } from "node:fs/promises";
|
|
12110
|
+
import path21 from "node:path";
|
|
11257
12111
|
class PublishSelectionsStore {
|
|
11258
12112
|
filePath;
|
|
11259
12113
|
queue = Promise.resolve();
|
|
11260
12114
|
constructor(filePath) {
|
|
11261
|
-
this.filePath = filePath ??
|
|
12115
|
+
this.filePath = filePath ?? path21.join(defaultConfigDir(), "publish-selections.json");
|
|
11262
12116
|
}
|
|
11263
12117
|
async read() {
|
|
11264
12118
|
try {
|
|
11265
|
-
const raw = await
|
|
12119
|
+
const raw = await readFile26(this.filePath, "utf8");
|
|
11266
12120
|
const parsed = JSON.parse(raw);
|
|
11267
12121
|
if (parsed && typeof parsed === "object") {
|
|
11268
12122
|
return {
|
|
@@ -11275,8 +12129,8 @@ class PublishSelectionsStore {
|
|
|
11275
12129
|
return { version: 1, global: {}, projects: {} };
|
|
11276
12130
|
}
|
|
11277
12131
|
async write(data) {
|
|
11278
|
-
await mkdir10(
|
|
11279
|
-
await
|
|
12132
|
+
await mkdir10(path21.dirname(this.filePath), { recursive: true });
|
|
12133
|
+
await writeFile9(this.filePath, JSON.stringify(data, null, 2), {
|
|
11280
12134
|
encoding: "utf8",
|
|
11281
12135
|
mode: 384
|
|
11282
12136
|
});
|
|
@@ -11341,4 +12195,4 @@ function normalizeProjectKey(projectDir) {
|
|
|
11341
12195
|
}
|
|
11342
12196
|
// src/lib/publish/manifest-publish.ts
|
|
11343
12197
|
import { isMap as isMap4 } from "yaml";
|
|
11344
|
-
export { PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, MANIFEST_FILENAMES, parseNpmPluginSpec, runLint, reportMissingTools, executeValidation, executeAndReport, splitOutPath, runBuild, openPath, startPreviewServer, scaffoldProject, BUILT_IN_TEMPLATE_IDS, addNpmPlugin, publishCredentialKey, publishConnectionStatus, listPublishAccounts, listPublishProviders, publishProviderFor, runPublish, connectPublishProvider };
|
|
12198
|
+
export { PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, MANIFEST_FILENAMES, loadManifestWithPath, parseNpmPluginSpec, runLint, reportMissingTools, executeValidation, executeAndReport, splitOutPath, runBuild, openPath, startPreviewServer, scaffoldProject, BUILT_IN_TEMPLATE_IDS, addNpmPlugin, publishCredentialKey, publishConnectionStatus, listPublishAccounts, connectGoogleDrive, listPublishProviders, publishProviderFor, resolvePublishFormat, runPublish, connectPublishProvider, disconnectPublishCredential };
|