gutterpress 0.10.4 → 0.10.5-beta.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.
Files changed (38) hide show
  1. package/README.md +7 -3
  2. package/dist/api/index.d.ts +7 -3
  3. package/dist/api/index.js +24 -2
  4. package/dist/{audit-6gsbjcyp.js → audit-9pf96y1w.js} +4 -4
  5. package/dist/{build-8gx7gkgw.js → build-jw9kynts.js} +4 -4
  6. package/dist/{cli-7hay1r0h.js → cli-0t4zfevc.js} +1 -1
  7. package/dist/{cli-zddg7r27.js → cli-5w9y6r2f.js} +928 -74
  8. package/dist/{cli-h617210r.js → cli-nb902265.js} +1 -1
  9. package/dist/{cli-d613m9n5.js → cli-xewqry0j.js} +1 -1
  10. package/dist/cli.js +14 -14
  11. package/dist/{doctor-mrqpw7g3.js → doctor-6vmxh7mz.js} +2 -2
  12. package/dist/{engine-dzrzyyew.js → engine-fwe8djyn.js} +1 -1
  13. package/dist/{engine-pta3yeyt.js → engine-mtyjc4v6.js} +2 -2
  14. package/dist/{index-1z8a090c.js → index-3xv7vwv2.js} +972 -120
  15. package/dist/{index-cm4dywtk.js → index-ja0p4w5f.js} +1 -1
  16. package/dist/{index-p4yq14qw.js → index-k5hcp8wj.js} +1 -1
  17. package/dist/index.js +25 -3
  18. package/dist/lib/markdown/gutterpress-css.d.ts +10 -2
  19. package/dist/lib/open-path.d.ts +27 -0
  20. package/dist/lib/publish/connect-google.d.ts +34 -0
  21. package/dist/lib/publish/connect.d.ts +23 -0
  22. package/dist/lib/publish/google-auth.d.ts +104 -0
  23. package/dist/lib/publish/google-drive.d.ts +88 -0
  24. package/dist/lib/publish/google-errors.d.ts +49 -0
  25. package/dist/lib/publish/providers/gdrive.d.ts +25 -0
  26. package/dist/lib/publish/run-publish.d.ts +14 -1
  27. package/dist/lib/publish/types.d.ts +46 -2
  28. package/dist/lib/remote-auth/token-store.d.ts +1 -1
  29. package/dist/{lint-gmymvnm3.js → lint-kzvbrn3d.js} +4 -4
  30. package/dist/{manifest.schema-084rxtwp.json → manifest.schema-hn6ac0ae.json} +25 -0
  31. package/dist/{new-hw7s7jne.js → new-cyna8tyn.js} +4 -4
  32. package/dist/{plugin-0qtfgjr2.js → plugin-mpc5v8sr.js} +4 -4
  33. package/dist/{preflight-j99dpj5e.js → preflight-fc178as2.js} +4 -4
  34. package/dist/{preview-sc3rcrkh.js → preview-qnbbtyqb.js} +4 -4
  35. package/dist/{publish-xwrwvrh0.js → publish-gmgpyh4n.js} +58 -9
  36. package/dist/schema/manifest.types.d.ts +20 -0
  37. package/dist/{validate-evzfg9m6.js → validate-0t3vjpmk.js} +4 -4
  38. package/package.json +1 -1
@@ -17,7 +17,7 @@ import {
17
17
  resolveChromiumExecutable,
18
18
  run,
19
19
  spawnCapture
20
- } from "./index-p4yq14qw.js";
20
+ } from "./index-k5hcp8wj.js";
21
21
  import {
22
22
  gitFs,
23
23
  gitScopeFor,
@@ -3978,7 +3978,7 @@ import git from "isomorphic-git";
3978
3978
  // package.json
3979
3979
  var package_default = {
3980
3980
  name: "gutterpress",
3981
- version: "0.10.4",
3981
+ version: "0.10.5-beta.1",
3982
3982
  description: "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
3983
3983
  author: "itlackey",
3984
3984
  license: "MPL-2.0",
@@ -8032,7 +8032,7 @@ class PdfOutput {
8032
8032
  const rawPdf = pdfxMode ? path8.join(stage, "raw.pdf") : path8.resolve(pdfFile);
8033
8033
  await fsp2.mkdir(path8.dirname(path8.resolve(pdfFile)), { recursive: true });
8034
8034
  log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
8035
- const { buildNativePdf } = await import("./engine-pta3yeyt.js");
8035
+ const { buildNativePdf } = await import("./engine-mtyjc4v6.js");
8036
8036
  const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
8037
8037
  title: config.title,
8038
8038
  author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
@@ -8747,24 +8747,24 @@ import { WebSocket, WebSocketServer } from "ws";
8747
8747
 
8748
8748
  // src/lib/open-path.ts
8749
8749
  import { spawn } from "node:child_process";
8750
+ function buildOpenPathSpawnSpec(filePath, platform = process.platform) {
8751
+ switch (platform) {
8752
+ case "darwin":
8753
+ return { cmd: "open", args: [filePath], options: { detached: true, stdio: "ignore" } };
8754
+ case "win32":
8755
+ return {
8756
+ cmd: "cmd",
8757
+ args: ["/c", "start", '""', '"' + filePath + '"'],
8758
+ options: { detached: true, stdio: "ignore", windowsVerbatimArguments: true }
8759
+ };
8760
+ default:
8761
+ return { cmd: "xdg-open", args: [filePath], options: { detached: true, stdio: "ignore" } };
8762
+ }
8763
+ }
8750
8764
  function openPath(filePath) {
8751
8765
  return new Promise((resolve9, reject) => {
8752
- let cmd;
8753
- let args;
8754
- switch (process.platform) {
8755
- case "darwin":
8756
- cmd = "open";
8757
- args = [filePath];
8758
- break;
8759
- case "win32":
8760
- cmd = "cmd";
8761
- args = ["/c", "start", "", filePath];
8762
- break;
8763
- default:
8764
- cmd = "xdg-open";
8765
- args = [filePath];
8766
- }
8767
- const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
8766
+ const { cmd, args, options } = buildOpenPathSpawnSpec(filePath);
8767
+ const child = spawn(cmd, args, options);
8768
8768
  child.once("error", reject);
8769
8769
  child.once("spawn", () => {
8770
8770
  child.unref();
@@ -12225,16 +12225,371 @@ function githubApiHeaders(token) {
12225
12225
  "X-GitHub-Api-Version": "2022-11-28"
12226
12226
  };
12227
12227
  }
12228
+ // src/lib/publish/google-auth.ts
12229
+ import crypto from "node:crypto";
12230
+ import http2 from "node:http";
12231
+
12232
+ // src/lib/publish/google-errors.ts
12233
+ function redactQueryCredentials(text) {
12234
+ return text.replace(/([?&](?:access_token|key)=)[^&\s"']+/gi, "$1(redacted)");
12235
+ }
12236
+ function parseGoogleApiError(status, body) {
12237
+ const err = body?.error;
12238
+ if (!err || typeof err !== "object")
12239
+ return { status };
12240
+ const e = err;
12241
+ const first = Array.isArray(e.errors) ? e.errors[0] : undefined;
12242
+ const reason = typeof first?.reason === "string" && first.reason ? first.reason : typeof e.status === "string" && e.status ? e.status : undefined;
12243
+ const message = typeof e.message === "string" && e.message.trim() ? redactQueryCredentials(e.message.trim()) : undefined;
12244
+ return { status, ...reason ? { reason } : {}, ...message ? { message } : {} };
12245
+ }
12246
+ async function readGoogleApiError(res) {
12247
+ const body = await res.json().catch(() => {
12248
+ return;
12249
+ });
12250
+ return parseGoogleApiError(res.status, body);
12251
+ }
12252
+ var RATE_LIMIT_REASONS = /^(dailyLimitExceeded|userRateLimitExceeded|rateLimitExceeded|quotaExceeded)$/i;
12253
+ var PERMISSION_REASONS = /^(insufficientPermissions|insufficientFilePermissions|forbidden|PERMISSION_DENIED)$/i;
12254
+ function hintFor(info2) {
12255
+ const reason = info2.reason ?? "";
12256
+ if (/^accessNotConfigured$/i.test(reason)) {
12257
+ 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).";
12258
+ }
12259
+ if (/^storageQuotaExceeded$/i.test(reason)) {
12260
+ return "Your Google Drive is full — free up space (or choose a different folder) and try again.";
12261
+ }
12262
+ if (RATE_LIMIT_REASONS.test(reason)) {
12263
+ return "Google Drive is rate-limiting requests right now. Wait a minute and try again.";
12264
+ }
12265
+ if (PERMISSION_REASONS.test(reason) || info2.status === 401) {
12266
+ return "This Google Drive connection doesn't have permission for that — disconnect and connect Google Drive again to re-approve access.";
12267
+ }
12268
+ return;
12269
+ }
12270
+ function googleApiFailure(what, info2) {
12271
+ const parts = [`${what} (HTTP ${info2.status}${info2.reason ? `, ${info2.reason}` : ""}).`];
12272
+ const hint = hintFor(info2);
12273
+ if (hint)
12274
+ parts.push(hint);
12275
+ if (info2.message)
12276
+ parts.push(`Google said: "${info2.message}"`);
12277
+ let text = parts.join(" ");
12278
+ if (!/\bgoogle\b/i.test(text))
12279
+ text = `Google Drive: ${text}`;
12280
+ return new FriendlyHttpError(text);
12281
+ }
12282
+
12283
+ // src/lib/publish/google-auth.ts
12284
+ var GDRIVE_HOST = "gdrive";
12285
+ var GOOGLE_OAUTH_SCOPE = "https://www.googleapis.com/auth/drive.file";
12286
+ var AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
12287
+ var TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
12288
+ var GOOGLE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
12289
+ var DRIVE_ABOUT_URL = "https://www.googleapis.com/drive/v3/about?fields=user(emailAddress)";
12290
+ var REQUEST_TIMEOUT_MS2 = 15000;
12291
+ var DEFAULT_FLOW_TIMEOUT_MS = 5 * 60000;
12292
+ var OFFLINE_MESSAGE2 = "Couldn't reach Google. Check your connection and try again.";
12293
+ var DEFAULT_GOOGLE_CLIENT_ID = "621278203862-2t35qditgvfh0pgosguqu2encf9c2h9j.apps.googleusercontent.com";
12294
+ var DEFAULT_GOOGLE_CLIENT_SECRET = "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";
12295
+ function resolveClientValue(explicit, env, embedded) {
12296
+ if (explicit !== undefined)
12297
+ return explicit.trim();
12298
+ if (env !== undefined)
12299
+ return env.trim();
12300
+ return embedded;
12301
+ }
12302
+ function resolveGoogleClientId(explicit) {
12303
+ return resolveClientValue(explicit, process.env.GUTTERPRESS_GOOGLE_CLIENT_ID, DEFAULT_GOOGLE_CLIENT_ID);
12304
+ }
12305
+ function resolveGoogleClientSecret(explicit) {
12306
+ return resolveClientValue(explicit, process.env.GUTTERPRESS_GOOGLE_CLIENT_SECRET, DEFAULT_GOOGLE_CLIENT_SECRET);
12307
+ }
12308
+ 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.";
12309
+ function requireGoogleClientCredentials(clientIdOpt, clientSecretOpt) {
12310
+ const clientId = resolveGoogleClientId(clientIdOpt);
12311
+ const clientSecret = resolveGoogleClientSecret(clientSecretOpt);
12312
+ if (!clientId || !clientSecret) {
12313
+ throw new Error(GOOGLE_NOT_CONFIGURED_MESSAGE);
12314
+ }
12315
+ return { clientId, clientSecret };
12316
+ }
12317
+ var RECONNECT_MESSAGE = "Your Google Drive connection expired or was revoked. Connect Google Drive again.";
12318
+ var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
12319
+ 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.";
12320
+ function grantedScopesInclude(scope, wanted) {
12321
+ if (scope === undefined)
12322
+ return true;
12323
+ return scope.split(/\s+/).includes(wanted);
12324
+ }
12325
+ function b64url(buf) {
12326
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
12327
+ }
12328
+ function pkceChallengeFromVerifier(verifier) {
12329
+ return b64url(crypto.createHash("sha256").update(verifier).digest());
12330
+ }
12331
+ async function safeFetch2(fetchImpl, url, init, timeoutMs = REQUEST_TIMEOUT_MS2) {
12332
+ return withFetchTimeout({ timeoutMs, signal: init.signal ?? undefined, offlineMessage: OFFLINE_MESSAGE2 }, (signal) => fetchImpl(url, { ...init, signal }));
12333
+ }
12334
+ function friendlyTokenError(status, body) {
12335
+ const code = body?.error;
12336
+ if (code === "invalid_grant")
12337
+ return new Error(RECONNECT_MESSAGE);
12338
+ if (code === "invalid_client") {
12339
+ return new Error("Google rejected the app's sign-in credentials. This build's Google OAuth client id/secret may be misconfigured.");
12340
+ }
12341
+ return new FriendlyHttpError(`Google sign-in failed (HTTP ${status}${code ? `: ${code}` : ""}). Please try again.`);
12342
+ }
12343
+ 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>`;
12344
+ 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>`;
12345
+ function waitForCallback(server, expectedState, redirectUri, timeoutMs, signal) {
12346
+ return new Promise((resolve9, reject) => {
12347
+ let settled = false;
12348
+ const cleanup = () => {
12349
+ clearTimeout(timer);
12350
+ server.removeListener("request", onRequest);
12351
+ signal?.removeEventListener("abort", onAbort);
12352
+ server.close();
12353
+ server.closeIdleConnections();
12354
+ };
12355
+ const finishResolve = (value) => {
12356
+ if (settled)
12357
+ return;
12358
+ settled = true;
12359
+ cleanup();
12360
+ resolve9(value);
12361
+ };
12362
+ const finishReject = (err) => {
12363
+ if (settled)
12364
+ return;
12365
+ settled = true;
12366
+ cleanup();
12367
+ reject(err);
12368
+ };
12369
+ const onRequest = (req, res) => {
12370
+ let url;
12371
+ try {
12372
+ url = new URL(req.url ?? "/", redirectUri);
12373
+ } catch {
12374
+ res.writeHead(400).end();
12375
+ return;
12376
+ }
12377
+ const err = url.searchParams.get("error");
12378
+ const gotState = url.searchParams.get("state");
12379
+ const code = url.searchParams.get("code");
12380
+ if (!err && !code) {
12381
+ res.writeHead(204, { Connection: "close" }).end();
12382
+ return;
12383
+ }
12384
+ const fail = (message) => {
12385
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" }).end(FAILURE_HTML);
12386
+ finishReject(new Error(message));
12387
+ };
12388
+ if (err) {
12389
+ fail("Google sign-in was declined. You can connect Google Drive again whenever you're ready.");
12390
+ return;
12391
+ }
12392
+ if (gotState !== expectedState) {
12393
+ fail("Google sign-in failed a security check (state mismatch). Connect Google Drive again.");
12394
+ return;
12395
+ }
12396
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" }).end(SUCCESS_HTML);
12397
+ finishResolve({ code });
12398
+ };
12399
+ const onAbort = () => finishReject(new Error("Google sign-in was canceled."));
12400
+ 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);
12401
+ server.on("request", onRequest);
12402
+ if (signal) {
12403
+ if (signal.aborted)
12404
+ return onAbort();
12405
+ signal.addEventListener("abort", onAbort, { once: true });
12406
+ }
12407
+ });
12408
+ }
12409
+
12410
+ class GoogleAuthProvider {
12411
+ clientIdOpt;
12412
+ clientSecretOpt;
12413
+ fetchImpl;
12414
+ openBrowser;
12415
+ timeoutMs;
12416
+ bindPort;
12417
+ constructor(options = {}) {
12418
+ this.clientIdOpt = options.clientId;
12419
+ this.clientSecretOpt = options.clientSecret;
12420
+ this.fetchImpl = options.fetchImpl ?? fetch;
12421
+ this.openBrowser = options.openBrowser ?? openPath;
12422
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_FLOW_TIMEOUT_MS;
12423
+ this.bindPort = options.port ?? 0;
12424
+ }
12425
+ async connect(callbacks) {
12426
+ const { clientId, clientSecret } = requireGoogleClientCredentials(this.clientIdOpt, this.clientSecretOpt);
12427
+ const { signal } = callbacks;
12428
+ const verifier = b64url(crypto.randomBytes(32));
12429
+ const state = b64url(crypto.randomBytes(16));
12430
+ const challenge = pkceChallengeFromVerifier(verifier);
12431
+ const server = http2.createServer();
12432
+ await new Promise((resolve9, reject) => {
12433
+ const onListenError = (err) => {
12434
+ reject(new Error(`Couldn't start the local sign-in listener: ${err.message}`));
12435
+ };
12436
+ server.once("error", onListenError);
12437
+ server.listen(this.bindPort, "127.0.0.1", () => {
12438
+ server.removeListener("error", onListenError);
12439
+ resolve9();
12440
+ });
12441
+ });
12442
+ const address = server.address();
12443
+ const port = typeof address === "object" && address ? address.port : 0;
12444
+ const redirectUri = `http://127.0.0.1:${port}`;
12445
+ const authUrl = `${AUTHORIZATION_ENDPOINT}?` + new URLSearchParams({
12446
+ client_id: clientId,
12447
+ redirect_uri: redirectUri,
12448
+ response_type: "code",
12449
+ scope: GOOGLE_OAUTH_SCOPE,
12450
+ code_challenge: challenge,
12451
+ code_challenge_method: "S256",
12452
+ state,
12453
+ access_type: "offline",
12454
+ prompt: "consent"
12455
+ }).toString();
12456
+ callbacks.onAuthUrl(authUrl);
12457
+ this.openBrowser(authUrl).catch(() => {});
12458
+ const { code } = await waitForCallback(server, state, redirectUri, this.timeoutMs, signal);
12459
+ const tokenRes = await safeFetch2(this.fetchImpl, TOKEN_ENDPOINT, {
12460
+ method: "POST",
12461
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
12462
+ body: new URLSearchParams({
12463
+ grant_type: "authorization_code",
12464
+ code,
12465
+ client_id: clientId,
12466
+ client_secret: clientSecret,
12467
+ redirect_uri: redirectUri,
12468
+ code_verifier: verifier
12469
+ }).toString(),
12470
+ signal
12471
+ });
12472
+ const tokenBody = await tokenRes.json().catch(() => ({}));
12473
+ if (!tokenRes.ok || !tokenBody.access_token) {
12474
+ throw friendlyTokenError(tokenRes.status, tokenBody);
12475
+ }
12476
+ if (!tokenBody.refresh_token) {
12477
+ 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.");
12478
+ }
12479
+ if (!grantedScopesInclude(tokenBody.scope, DRIVE_FILE_SCOPE)) {
12480
+ throw new Error(DRIVE_PERMISSION_NOT_GRANTED_MESSAGE);
12481
+ }
12482
+ const email = await this.fetchEmail(tokenBody.access_token);
12483
+ return {
12484
+ host: GDRIVE_HOST,
12485
+ kind: "google-oauth",
12486
+ token: tokenBody.refresh_token,
12487
+ ...email ? { username: email, label: `Google Drive — ${email}` } : { label: "Google Drive" },
12488
+ createdAt: Date.now()
12489
+ };
12490
+ }
12491
+ async fetchEmail(accessToken) {
12492
+ let res;
12493
+ try {
12494
+ res = await safeFetch2(this.fetchImpl, DRIVE_ABOUT_URL, {
12495
+ method: "GET",
12496
+ headers: { Authorization: `Bearer ${accessToken}` }
12497
+ });
12498
+ } catch {
12499
+ return;
12500
+ }
12501
+ if (res.status === 401 || res.status === 403) {
12502
+ throw googleApiFailure("Google Drive rejected the new connection", await readGoogleApiError(res));
12503
+ }
12504
+ if (!res.ok)
12505
+ return;
12506
+ try {
12507
+ const body = await res.json();
12508
+ return body.user?.emailAddress;
12509
+ } catch {
12510
+ return;
12511
+ }
12512
+ }
12513
+ }
12514
+ async function revokeGoogleCredential(refreshToken, options = {}) {
12515
+ const fetchImpl = options.fetchImpl ?? fetch;
12516
+ try {
12517
+ await withFetchTimeout({ timeoutMs: 1e4, offlineMessage: OFFLINE_MESSAGE2 }, (signal) => fetchImpl(GOOGLE_REVOKE_ENDPOINT, {
12518
+ method: "POST",
12519
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
12520
+ body: new URLSearchParams({ token: refreshToken }).toString(),
12521
+ signal
12522
+ }));
12523
+ } catch {}
12524
+ }
12525
+ // src/lib/publish/types.ts
12526
+ function publishCredentialKey(host, account) {
12527
+ const a = (account ?? "").trim();
12528
+ return a ? `${host}#${a}` : host;
12529
+ }
12530
+ async function resolvePublishCredential(info2, deps, account = deps.credentialAccount) {
12531
+ const env = deps.env ?? process.env;
12532
+ const fromEnv = info2.credential.envVar ? env[info2.credential.envVar]?.trim() : undefined;
12533
+ if (fromEnv) {
12534
+ return {
12535
+ credential: {
12536
+ host: info2.credential.host,
12537
+ kind: "token",
12538
+ token: fromEnv,
12539
+ createdAt: 0
12540
+ },
12541
+ source: "env"
12542
+ };
12543
+ }
12544
+ const stored = await deps.tokenStore.get(publishCredentialKey(info2.credential.host, account));
12545
+ return stored ? { credential: stored, source: "store" } : null;
12546
+ }
12547
+ async function publishConnectionStatus(info2, deps, account = deps.credentialAccount) {
12548
+ if (!info2.credential.required)
12549
+ return { connected: true };
12550
+ const resolved = await resolvePublishCredential(info2, deps, account);
12551
+ return resolved ? { connected: true, source: resolved.source } : { connected: false };
12552
+ }
12553
+ async function listPublishAccounts(info2, deps) {
12554
+ const wantHost = info2.credential.host.trim().toLowerCase();
12555
+ const all = await deps.tokenStore.list();
12556
+ return all.filter((c) => c.host.trim().toLowerCase() === wantHost).map((c) => ({
12557
+ account: (c.username ?? "").trim(),
12558
+ label: c.label ?? (c.username || info2.label),
12559
+ createdAt: c.createdAt
12560
+ }));
12561
+ }
12562
+
12563
+ // src/lib/publish/connect-google.ts
12564
+ async function connectGoogleDrive(options, deps, callbacks) {
12565
+ const provider = new GoogleAuthProvider({
12566
+ clientId: options.clientId,
12567
+ clientSecret: options.clientSecret,
12568
+ fetchImpl: deps.fetch,
12569
+ ...options.openBrowser ? { openBrowser: options.openBrowser } : {}
12570
+ });
12571
+ const credential = await provider.connect(callbacks);
12572
+ const email = credential.username;
12573
+ const account = (options.account ?? "").trim();
12574
+ const key = publishCredentialKey(GDRIVE_HOST, account);
12575
+ const { username: _accountEmailAsUsername, ...credentialWithoutUsername } = credential;
12576
+ await deps.tokenStore.set(key, {
12577
+ ...credentialWithoutUsername,
12578
+ ...account ? { username: account } : {},
12579
+ label: account ? `${account} (${email ?? "Google Drive"})` : credential.label
12580
+ });
12581
+ return { connected: true, ...email ? { email } : {} };
12582
+ }
12228
12583
  // src/lib/remote-auth/github-repos.ts
12229
12584
  var API_BASE2 = "https://api.github.com";
12230
- var REQUEST_TIMEOUT_MS2 = 15000;
12585
+ var REQUEST_TIMEOUT_MS3 = 15000;
12231
12586
  var PER_PAGE = 100;
12232
12587
  var MAX_PAGES = 50;
12233
- var RECONNECT_MESSAGE = "Your GitHub connection has expired. Reconnect GitHub and try again.";
12588
+ var RECONNECT_MESSAGE2 = "Your GitHub connection has expired. Reconnect GitHub and try again.";
12234
12589
  async function apiGet(fetchImpl, url, token) {
12235
- const res = await withFetchTimeout({ timeoutMs: REQUEST_TIMEOUT_MS2, offlineMessage: OFFLINE_MESSAGE }, (signal) => fetchImpl(url, { method: "GET", headers: githubApiHeaders(token), signal }));
12590
+ const res = await withFetchTimeout({ timeoutMs: REQUEST_TIMEOUT_MS3, offlineMessage: OFFLINE_MESSAGE }, (signal) => fetchImpl(url, { method: "GET", headers: githubApiHeaders(token), signal }));
12236
12591
  if (res.status === 401 || res.status === 403) {
12237
- throw new Error(RECONNECT_MESSAGE);
12592
+ throw new Error(RECONNECT_MESSAGE2);
12238
12593
  }
12239
12594
  if (!res.ok) {
12240
12595
  throw new Error(`GitHub returned an unexpected error (HTTP ${res.status}). Please try again.`);
@@ -12344,7 +12699,7 @@ function isSmallBody(body) {
12344
12699
  function gitTimeoutError(what, ms) {
12345
12700
  return new Error(`Git network operation timed out after ${Math.round(ms / 1000)}s (${what}); ` + `couldn't reach the remote (ETIMEDOUT).`);
12346
12701
  }
12347
- function withIdleTimeout(http2, idleMs = GIT_HTTP_IDLE_TIMEOUT_MS, uploadMs = GIT_HTTP_UPLOAD_TIMEOUT_MS) {
12702
+ function withIdleTimeout(http3, idleMs = GIT_HTTP_IDLE_TIMEOUT_MS, uploadMs = GIT_HTTP_UPLOAD_TIMEOUT_MS) {
12348
12703
  return {
12349
12704
  async request(options) {
12350
12705
  let timer;
@@ -12373,7 +12728,7 @@ function withIdleTimeout(http2, idleMs = GIT_HTTP_IDLE_TIMEOUT_MS, uploadMs = GI
12373
12728
  arm(isLargeUpload ? uploadMs : idleMs, isLargeUpload ? "the upload did not complete" : "the remote did not respond");
12374
12729
  let res;
12375
12730
  try {
12376
- res = await race(http2.request(options));
12731
+ res = await race(http3.request(options));
12377
12732
  } finally {
12378
12733
  disarm();
12379
12734
  }
@@ -12582,12 +12937,12 @@ async function guardTrackingRef(dir, ref, cache2, fn) {
12582
12937
  throw e;
12583
12938
  }
12584
12939
  }
12585
- async function fetchRemoteTip(dir, branch, transport, http2, cache2) {
12940
+ async function fetchRemoteTip(dir, branch, transport, http3, cache2) {
12586
12941
  try {
12587
12942
  const trackingRef = `refs/remotes/${transport.remote}/${branch}`;
12588
12943
  const result = await guardTrackingRef(dir, trackingRef, cache2, () => git2.fetch({
12589
12944
  fs: gitFs,
12590
- http: http2,
12945
+ http: http3,
12591
12946
  dir,
12592
12947
  cache: cache2,
12593
12948
  remote: transport.remote,
@@ -13202,7 +13557,7 @@ function defaultSleep2(ms) {
13202
13557
  return new Promise((resolve9) => setTimeout(resolve9, ms));
13203
13558
  }
13204
13559
  async function syncProject(options) {
13205
- const http2 = options.httpClient ?? defaultGitHttp;
13560
+ const http3 = options.httpClient ?? defaultGitHttp;
13206
13561
  const push = options.push ?? true;
13207
13562
  const attempts = Math.max(1, options.retry?.attempts ?? DEFAULT_SYNC_RETRY.attempts);
13208
13563
  const backoffMs = Math.max(0, options.retry?.backoffMs ?? DEFAULT_SYNC_RETRY.backoffMs);
@@ -13239,7 +13594,7 @@ async function syncProject(options) {
13239
13594
  }
13240
13595
  for (let attempt = 0;attempt < attempts; attempt++) {
13241
13596
  logger.info("sync", `sync pass ${attempt + 1}/${attempts}`);
13242
- const remoteTip = await fetchRemoteTip(dir, branch, transport, http2, cache2);
13597
+ const remoteTip = await fetchRemoteTip(dir, branch, transport, http3, cache2);
13243
13598
  let localTip = await git6.resolveRef({ fs: gitFs, dir, ref: branch });
13244
13599
  if (push || remoteTip !== null && remoteTip !== localTip) {
13245
13600
  const lateSnapshot = await snapshotBeforeAction({
@@ -13312,7 +13667,7 @@ async function syncProject(options) {
13312
13667
  try {
13313
13668
  await git6.push({
13314
13669
  fs: gitFs,
13315
- http: http2,
13670
+ http: http3,
13316
13671
  dir,
13317
13672
  cache: cache2,
13318
13673
  remote: transport.remote,
@@ -13351,8 +13706,8 @@ async function syncProject(options) {
13351
13706
  });
13352
13707
  }
13353
13708
  // src/lib/publish/run-publish.ts
13354
- import { readdir as readdir11, stat as stat10 } from "node:fs/promises";
13355
- import path26 from "node:path";
13709
+ import { readdir as readdir12, stat as stat11 } from "node:fs/promises";
13710
+ import path27 from "node:path";
13356
13711
 
13357
13712
  // src/lib/publish/command-runner.ts
13358
13713
  var CAPTURE_LIMIT = 64 * 1024;
@@ -13409,44 +13764,6 @@ async function commandExists(cmd, runCommand = defaultCommandRunner, env) {
13409
13764
  }
13410
13765
  }
13411
13766
 
13412
- // src/lib/publish/types.ts
13413
- function publishCredentialKey(host, account) {
13414
- const a = (account ?? "").trim();
13415
- return a ? `${host}#${a}` : host;
13416
- }
13417
- async function resolvePublishCredential(info2, deps, account = deps.credentialAccount) {
13418
- const env = deps.env ?? process.env;
13419
- const fromEnv = info2.credential.envVar ? env[info2.credential.envVar]?.trim() : undefined;
13420
- if (fromEnv) {
13421
- return {
13422
- credential: {
13423
- host: info2.credential.host,
13424
- kind: "token",
13425
- token: fromEnv,
13426
- createdAt: 0
13427
- },
13428
- source: "env"
13429
- };
13430
- }
13431
- const stored = await deps.tokenStore.get(publishCredentialKey(info2.credential.host, account));
13432
- return stored ? { credential: stored, source: "store" } : null;
13433
- }
13434
- async function publishConnectionStatus(info2, deps, account = deps.credentialAccount) {
13435
- if (!info2.credential.required)
13436
- return { connected: true };
13437
- const resolved = await resolvePublishCredential(info2, deps, account);
13438
- return resolved ? { connected: true, source: resolved.source } : { connected: false };
13439
- }
13440
- async function listPublishAccounts(info2, deps) {
13441
- const wantHost = info2.credential.host.trim().toLowerCase();
13442
- const all = await deps.tokenStore.list();
13443
- return all.filter((c) => c.host.trim().toLowerCase() === wantHost).map((c) => ({
13444
- account: (c.username ?? "").trim(),
13445
- label: c.label ?? (c.username || info2.label),
13446
- createdAt: c.createdAt
13447
- }));
13448
- }
13449
-
13450
13767
  // src/lib/publish/providers/azure-swa.ts
13451
13768
  var AZURE_SWA_HOST = "azure-swa";
13452
13769
  var info2 = {
@@ -13619,9 +13936,521 @@ var drivethrurpgProvider = {
13619
13936
  }
13620
13937
  };
13621
13938
 
13622
- // src/lib/publish/butler.ts
13623
- import { chmod as chmod2, mkdir as mkdir17, stat as stat9, writeFile as writeFile14 } from "node:fs/promises";
13939
+ // src/lib/publish/providers/gdrive.ts
13940
+ import { mkdtemp as mkdtemp4, readFile as readFile31, readdir as readdir11, rm as rm9, stat as stat9, writeFile as writeFile14 } from "node:fs/promises";
13941
+ import { tmpdir as tmpdir4 } from "node:os";
13624
13942
  import path25 from "node:path";
13943
+ import { Zip, ZipDeflate } from "fflate";
13944
+
13945
+ // src/lib/publish/google-drive.ts
13946
+ import { open as open3 } from "node:fs/promises";
13947
+ var TOKEN_ENDPOINT2 = "https://oauth2.googleapis.com/token";
13948
+ var DRIVE_API_BASE = "https://www.googleapis.com/drive/v3";
13949
+ var DRIVE_UPLOAD_BASE = "https://www.googleapis.com/upload/drive/v3/files";
13950
+ var METADATA_TIMEOUT_MS2 = 30000;
13951
+ var TOKEN_REFRESH_TIMEOUT_MS = 15000;
13952
+ var CHUNK_TIMEOUT_MS = 120000;
13953
+ var OFFLINE_MESSAGE3 = "Couldn't reach Google Drive. Check your connection and try again.";
13954
+ async function driveFetch(fetchImpl, url, init, timeoutMs = METADATA_TIMEOUT_MS2) {
13955
+ return withFetchTimeout({ timeoutMs, offlineMessage: OFFLINE_MESSAGE3 }, (signal) => fetchImpl(url, { ...init, signal }));
13956
+ }
13957
+ function authHeaders(accessToken) {
13958
+ return { Authorization: `Bearer ${accessToken}` };
13959
+ }
13960
+ async function driveFailure(res, what) {
13961
+ return googleApiFailure(what, await readGoogleApiError(res));
13962
+ }
13963
+ function escapeDriveQueryValue(value) {
13964
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
13965
+ }
13966
+ async function refreshAccessToken(fetchImpl, params) {
13967
+ const res = await driveFetch(fetchImpl, TOKEN_ENDPOINT2, {
13968
+ method: "POST",
13969
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
13970
+ body: new URLSearchParams({
13971
+ grant_type: "refresh_token",
13972
+ refresh_token: params.refreshToken,
13973
+ client_id: params.clientId,
13974
+ client_secret: params.clientSecret
13975
+ }).toString()
13976
+ }, TOKEN_REFRESH_TIMEOUT_MS);
13977
+ const body = await res.json().catch(() => ({}));
13978
+ if (!res.ok || !body.access_token) {
13979
+ if (body.error === "invalid_grant")
13980
+ throw new Error(RECONNECT_MESSAGE);
13981
+ throw new FriendlyHttpError(`Google rejected the Drive connection (HTTP ${res.status}${body.error ? `: ${body.error}` : ""}). ${RECONNECT_MESSAGE}`);
13982
+ }
13983
+ return { accessToken: body.access_token, expiresIn: body.expires_in ?? 3600 };
13984
+ }
13985
+ async function driveAbout(fetchImpl, accessToken) {
13986
+ const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/about?fields=user(emailAddress),storageQuota`, {
13987
+ method: "GET",
13988
+ headers: authHeaders(accessToken)
13989
+ });
13990
+ if (!res.ok)
13991
+ throw await driveFailure(res, "Couldn't read your Google Drive account info");
13992
+ const body = await res.json();
13993
+ const limit = body.storageQuota?.limit != null ? Number(body.storageQuota.limit) : null;
13994
+ const usage = Number(body.storageQuota?.usage ?? 0);
13995
+ return {
13996
+ email: body.user?.emailAddress,
13997
+ quota: {
13998
+ limitBytes: limit,
13999
+ usageBytes: usage,
14000
+ freeBytes: limit != null ? Math.max(0, limit - usage) : null
14001
+ }
14002
+ };
14003
+ }
14004
+ var FOLDER_MIME = "application/vnd.google-apps.folder";
14005
+ async function listFolders(fetchImpl, accessToken) {
14006
+ const q = `mimeType='${FOLDER_MIME}' and trashed=false`;
14007
+ const all = [];
14008
+ let pageToken;
14009
+ do {
14010
+ const params = new URLSearchParams({
14011
+ q,
14012
+ fields: "nextPageToken,files(id,name)",
14013
+ orderBy: "modifiedTime desc",
14014
+ pageSize: "100"
14015
+ });
14016
+ if (pageToken)
14017
+ params.set("pageToken", pageToken);
14018
+ const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files?${params.toString()}`, {
14019
+ method: "GET",
14020
+ headers: authHeaders(accessToken)
14021
+ });
14022
+ if (!res.ok)
14023
+ throw await driveFailure(res, "Couldn't list Google Drive folders");
14024
+ const body = await res.json();
14025
+ all.push(...body.files ?? []);
14026
+ pageToken = body.nextPageToken;
14027
+ } while (pageToken);
14028
+ return all;
14029
+ }
14030
+ async function getFolderById(fetchImpl, accessToken, folderId) {
14031
+ const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files/${encodeURIComponent(folderId)}?fields=id,name,trashed,mimeType`, {
14032
+ method: "GET",
14033
+ headers: authHeaders(accessToken)
14034
+ });
14035
+ if (res.status === 404)
14036
+ return null;
14037
+ if (!res.ok)
14038
+ throw await driveFailure(res, "Couldn't look up the Google Drive folder");
14039
+ const body = await res.json();
14040
+ if (body.trashed || body.mimeType !== FOLDER_MIME)
14041
+ return null;
14042
+ return { id: body.id, name: body.name };
14043
+ }
14044
+ async function createFolder(fetchImpl, accessToken, name) {
14045
+ const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files?fields=id,name`, {
14046
+ method: "POST",
14047
+ headers: { ...authHeaders(accessToken), "Content-Type": "application/json" },
14048
+ body: JSON.stringify({ name, mimeType: FOLDER_MIME })
14049
+ });
14050
+ if (!res.ok)
14051
+ throw await driveFailure(res, `Couldn't create the Google Drive folder "${name}"`);
14052
+ return await res.json();
14053
+ }
14054
+ async function findFolderByName(fetchImpl, accessToken, name) {
14055
+ const q = `name='${escapeDriveQueryValue(name)}' and mimeType='${FOLDER_MIME}' and trashed=false`;
14056
+ const params = new URLSearchParams({
14057
+ q,
14058
+ fields: "files(id,name)",
14059
+ orderBy: "modifiedTime desc",
14060
+ pageSize: "1"
14061
+ });
14062
+ const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files?${params.toString()}`, {
14063
+ method: "GET",
14064
+ headers: authHeaders(accessToken)
14065
+ });
14066
+ if (!res.ok)
14067
+ throw await driveFailure(res, `Couldn't look up the Google Drive folder "${name}"`);
14068
+ const body = await res.json();
14069
+ return body.files?.[0] ?? null;
14070
+ }
14071
+ async function ensureFolder(fetchImpl, accessToken, name) {
14072
+ const found = await findFolderByName(fetchImpl, accessToken, name);
14073
+ if (found)
14074
+ return found;
14075
+ return createFolder(fetchImpl, accessToken, name);
14076
+ }
14077
+ async function findFileInFolder(fetchImpl, accessToken, folderId, name) {
14078
+ const q = `name='${escapeDriveQueryValue(name)}' and '${escapeDriveQueryValue(folderId)}' in parents and trashed=false`;
14079
+ const res = await driveFetch(fetchImpl, `${DRIVE_API_BASE}/files?q=${encodeURIComponent(q)}&fields=files(id,name,webViewLink)&pageSize=1`, { method: "GET", headers: authHeaders(accessToken) });
14080
+ if (!res.ok)
14081
+ throw await driveFailure(res, "Couldn't search the Google Drive folder");
14082
+ const body = await res.json();
14083
+ return body.files?.[0] ?? null;
14084
+ }
14085
+ var RESUMABLE_CHUNK_SIZE = 8 * 1024 * 1024;
14086
+ var MAX_CHUNK_RETRIES = 3;
14087
+ var RETRY_BASE_DELAY_MS = 500;
14088
+ function defaultSleep3(ms) {
14089
+ return new Promise((resolve9) => setTimeout(resolve9, ms));
14090
+ }
14091
+ async function startResumableSession(fetchImpl, accessToken, opts) {
14092
+ const mimeType = opts.mimeType ?? "application/pdf";
14093
+ 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`;
14094
+ const res = await driveFetch(fetchImpl, url, {
14095
+ method: opts.fileId ? "PATCH" : "POST",
14096
+ headers: {
14097
+ ...authHeaders(accessToken),
14098
+ "Content-Type": "application/json; charset=UTF-8",
14099
+ "X-Upload-Content-Type": mimeType,
14100
+ "X-Upload-Content-Length": String(opts.totalBytes)
14101
+ },
14102
+ body: JSON.stringify(opts.fileId ? {} : { name: opts.name, parents: opts.parentFolderId ? [opts.parentFolderId] : undefined })
14103
+ });
14104
+ const session = res.headers.get("location");
14105
+ if (!res.ok || !session)
14106
+ throw await driveFailure(res, "Couldn't start the Google Drive upload");
14107
+ return session;
14108
+ }
14109
+ async function queryUploadStatus(fetchImpl, sessionUrl, total) {
14110
+ let res;
14111
+ try {
14112
+ res = await driveFetch(fetchImpl, sessionUrl, { method: "PUT", headers: { "Content-Range": `bytes */${total}` } }, CHUNK_TIMEOUT_MS);
14113
+ } catch {
14114
+ return null;
14115
+ }
14116
+ if (res.status === 308) {
14117
+ const range = res.headers.get("range");
14118
+ return { kind: "progress", receivedBytes: range ? Number(range.split("-")[1]) + 1 : 0 };
14119
+ }
14120
+ if (res.status === 200 || res.status === 201) {
14121
+ return { kind: "done", file: await res.json() };
14122
+ }
14123
+ return null;
14124
+ }
14125
+ async function putChunkWithRetry(fetchImpl, sessionUrl, buf, start, total, maxRetries, sleep) {
14126
+ let attempt = 0;
14127
+ for (;; ) {
14128
+ let res;
14129
+ try {
14130
+ res = await driveFetch(fetchImpl, sessionUrl, {
14131
+ method: "PUT",
14132
+ headers: {
14133
+ "Content-Range": `bytes ${start}-${start + buf.length - 1}/${total}`,
14134
+ "Content-Length": String(buf.length)
14135
+ },
14136
+ body: buf
14137
+ }, CHUNK_TIMEOUT_MS);
14138
+ } catch (e) {
14139
+ if (attempt >= maxRetries)
14140
+ throw e;
14141
+ attempt++;
14142
+ const status = await queryUploadStatus(fetchImpl, sessionUrl, total);
14143
+ if (status?.kind === "done")
14144
+ return { done: true, file: status.file };
14145
+ if (status?.kind === "progress" && status.receivedBytes > start) {
14146
+ const chunkEnd = start + buf.length;
14147
+ if (status.receivedBytes >= chunkEnd) {
14148
+ return { done: false, nextOffset: status.receivedBytes };
14149
+ }
14150
+ buf = buf.subarray(status.receivedBytes - start);
14151
+ start = status.receivedBytes;
14152
+ }
14153
+ await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
14154
+ continue;
14155
+ }
14156
+ if (res.status === 308) {
14157
+ const range = res.headers.get("range");
14158
+ const nextOffset = range ? Number(range.split("-")[1]) + 1 : start + buf.length;
14159
+ return { done: false, nextOffset };
14160
+ }
14161
+ if (res.status === 200 || res.status === 201) {
14162
+ return { done: true, file: await res.json() };
14163
+ }
14164
+ if ((res.status === 429 || res.status >= 500) && attempt < maxRetries) {
14165
+ attempt++;
14166
+ const retryAfter = res.headers.get("retry-after");
14167
+ const delayMs = retryAfter ? Number(retryAfter) * 1000 : RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
14168
+ await sleep(Number.isFinite(delayMs) && delayMs > 0 ? delayMs : RETRY_BASE_DELAY_MS);
14169
+ continue;
14170
+ }
14171
+ throw await driveFailure(res, "Google Drive upload failed");
14172
+ }
14173
+ }
14174
+ async function resumableUpload(fetchImpl, accessToken, opts) {
14175
+ const chunkSize = opts.chunkSize ?? RESUMABLE_CHUNK_SIZE;
14176
+ const maxRetries = opts.maxRetriesPerChunk ?? MAX_CHUNK_RETRIES;
14177
+ const sleep = opts.sleepImpl ?? defaultSleep3;
14178
+ const sessionUrl = await startResumableSession(fetchImpl, accessToken, opts);
14179
+ const fh = await open3(opts.filePath, "r");
14180
+ try {
14181
+ let offset = 0;
14182
+ for (;; ) {
14183
+ const len = Math.min(chunkSize, opts.totalBytes - offset);
14184
+ const buf = Buffer.allocUnsafe(len);
14185
+ const { bytesRead } = await fh.read(buf, 0, len, offset);
14186
+ if (bytesRead !== len) {
14187
+ 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.`);
14188
+ }
14189
+ const result = await putChunkWithRetry(fetchImpl, sessionUrl, buf, offset, opts.totalBytes, maxRetries, sleep);
14190
+ if (result.done) {
14191
+ opts.onProgress?.(opts.totalBytes, opts.totalBytes);
14192
+ return result.file;
14193
+ }
14194
+ offset = result.nextOffset;
14195
+ opts.onProgress?.(Math.min(offset, opts.totalBytes), opts.totalBytes);
14196
+ if (offset >= opts.totalBytes) {
14197
+ throw new FriendlyHttpError("Google Drive accepted every byte but never confirmed the upload. Try publishing again.");
14198
+ }
14199
+ }
14200
+ } finally {
14201
+ await fh.close();
14202
+ }
14203
+ }
14204
+
14205
+ // src/lib/publish/providers/gdrive.ts
14206
+ var DEFAULT_FOLDER_NAME = "Gutterpress";
14207
+ var NOT_CONNECTED_MESSAGE = "Google Drive isn't connected. Run `gutterpress publish --provider gdrive --connect` (or set GDRIVE_REFRESH_TOKEN) first.";
14208
+ var info4 = {
14209
+ id: "gdrive",
14210
+ label: "Google Drive",
14211
+ kind: "api",
14212
+ format: "pdf",
14213
+ formats: ["pdf", "html"],
14214
+ 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.",
14215
+ configFields: [{ key: "folder", label: "Drive folder", placeholder: DEFAULT_FOLDER_NAME }],
14216
+ credential: {
14217
+ required: true,
14218
+ host: GDRIVE_HOST,
14219
+ envVar: "GDRIVE_REFRESH_TOKEN",
14220
+ connect: "oauth",
14221
+ hint: "Click Connect Google Drive and approve in your browser — nothing to paste."
14222
+ },
14223
+ destinations: { label: "Folder", canCreate: true }
14224
+ };
14225
+ function readConfig(req) {
14226
+ const cfg = req.config;
14227
+ return {
14228
+ folder: cfg.folder?.trim() || undefined,
14229
+ folderId: cfg.folderId?.trim() || undefined
14230
+ };
14231
+ }
14232
+ function formatBytes(bytes) {
14233
+ if (bytes < 1024 * 1024)
14234
+ return `${(bytes / 1024).toFixed(0)} KB`;
14235
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
14236
+ }
14237
+ async function mintAccessToken(resolved, fetchImpl) {
14238
+ const { clientId, clientSecret } = requireGoogleClientCredentials();
14239
+ const { accessToken } = await refreshAccessToken(fetchImpl, {
14240
+ clientId,
14241
+ clientSecret,
14242
+ refreshToken: resolved.credential.token
14243
+ });
14244
+ return accessToken;
14245
+ }
14246
+ async function getAccessToken(req) {
14247
+ const resolved = await resolvePublishCredential(info4, req.deps);
14248
+ if (!resolved)
14249
+ throw new Error(NOT_CONNECTED_MESSAGE);
14250
+ const accessToken = await mintAccessToken(resolved, req.deps.fetch ?? globalThis.fetch);
14251
+ return { accessToken, source: resolved.source };
14252
+ }
14253
+ async function resolveFolder(fetchImpl, accessToken, cfg) {
14254
+ if (cfg.folderId) {
14255
+ const folder = await getFolderById(fetchImpl, accessToken, cfg.folderId);
14256
+ if (!folder) {
14257
+ 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.`);
14258
+ }
14259
+ return folder;
14260
+ }
14261
+ return ensureFolder(fetchImpl, accessToken, cfg.folder || DEFAULT_FOLDER_NAME);
14262
+ }
14263
+ function folderUrl(id) {
14264
+ return `https://drive.google.com/drive/folders/${id}`;
14265
+ }
14266
+ function toDestination(folder) {
14267
+ return { id: folder.id, title: folder.name, url: folderUrl(folder.id) };
14268
+ }
14269
+ var ZIP_CHUNK_BYTES = 64 * 1024;
14270
+ function yieldToEventLoop() {
14271
+ return new Promise((resolve9) => setImmediate(resolve9));
14272
+ }
14273
+ function zipEntriesNonBlocking(entries, opts) {
14274
+ return new Promise((resolve9, reject) => {
14275
+ const chunks = [];
14276
+ let total = 0;
14277
+ let settled = false;
14278
+ const archive = new Zip((err, chunk2, final) => {
14279
+ if (settled)
14280
+ return;
14281
+ if (err) {
14282
+ settled = true;
14283
+ reject(err);
14284
+ return;
14285
+ }
14286
+ if (chunk2) {
14287
+ chunks.push(chunk2);
14288
+ total += chunk2.length;
14289
+ }
14290
+ if (final) {
14291
+ settled = true;
14292
+ const out = new Uint8Array(total);
14293
+ let offset = 0;
14294
+ for (const c of chunks) {
14295
+ out.set(c, offset);
14296
+ offset += c.length;
14297
+ }
14298
+ resolve9(out);
14299
+ }
14300
+ });
14301
+ (async () => {
14302
+ for (const [name, data] of Object.entries(entries)) {
14303
+ const stream = new ZipDeflate(name, opts);
14304
+ archive.add(stream);
14305
+ if (data.length === 0) {
14306
+ stream.push(new Uint8Array(0), true);
14307
+ continue;
14308
+ }
14309
+ for (let offset = 0;offset < data.length; offset += ZIP_CHUNK_BYTES) {
14310
+ const end = Math.min(offset + ZIP_CHUNK_BYTES, data.length);
14311
+ stream.push(data.subarray(offset, end), end >= data.length);
14312
+ await yieldToEventLoop();
14313
+ }
14314
+ }
14315
+ archive.end();
14316
+ })().catch((e) => {
14317
+ if (!settled) {
14318
+ settled = true;
14319
+ reject(e instanceof Error ? e : new Error(String(e)));
14320
+ }
14321
+ });
14322
+ });
14323
+ }
14324
+ async function collectZipEntries(root, dir, out, onWarn) {
14325
+ const entries = await readdir11(dir, { withFileTypes: true });
14326
+ for (const entry of entries) {
14327
+ const full = path25.join(dir, entry.name);
14328
+ if (entry.isDirectory()) {
14329
+ await collectZipEntries(root, full, out, onWarn);
14330
+ } else if (entry.isFile()) {
14331
+ const rel = path25.relative(root, full).split(path25.sep).join("/");
14332
+ out[rel] = await readFile31(full);
14333
+ } else if (entry.isSymbolicLink()) {
14334
+ const resolved = await stat9(full).catch(() => null);
14335
+ if (resolved?.isFile()) {
14336
+ const rel = path25.relative(root, full).split(path25.sep).join("/");
14337
+ out[rel] = await readFile31(full);
14338
+ } else if (resolved?.isDirectory()) {
14339
+ await collectZipEntries(root, full, out, onWarn);
14340
+ } else {
14341
+ onWarn?.(`Skipped "${path25.relative(root, full)}" in the website export — it's a broken symlink.`);
14342
+ }
14343
+ } else {
14344
+ onWarn?.(`Skipped "${path25.relative(root, full)}" in the website export — not a file, folder, or symlink.`);
14345
+ }
14346
+ }
14347
+ }
14348
+ async function zipHtmlExport(exportDir, title, onWarn, writeArchiveImpl = writeFile14) {
14349
+ const entries = {};
14350
+ await collectZipEntries(exportDir, exportDir, entries, onWarn);
14351
+ const fileName = `${bookSlug(title)}-website.zip`;
14352
+ const tmpRoot = await mkdtemp4(path25.join(tmpdir4(), "gutterpress-gdrive-"));
14353
+ try {
14354
+ const filePath = path25.join(tmpRoot, fileName);
14355
+ const zipped = await zipEntriesNonBlocking(entries, { level: 6 });
14356
+ await writeArchiveImpl(filePath, zipped);
14357
+ return {
14358
+ filePath,
14359
+ fileName,
14360
+ mimeType: "application/zip",
14361
+ cleanup: () => rm9(tmpRoot, { recursive: true, force: true })
14362
+ };
14363
+ } catch (e) {
14364
+ await rm9(tmpRoot, { recursive: true, force: true }).catch(() => {});
14365
+ throw e;
14366
+ }
14367
+ }
14368
+ var gdriveProvider = {
14369
+ info: info4,
14370
+ async authenticate(req) {
14371
+ const resolved = await resolvePublishCredential(info4, req.deps);
14372
+ if (!resolved)
14373
+ return { ok: false, message: NOT_CONNECTED_MESSAGE };
14374
+ try {
14375
+ const accessToken = await mintAccessToken(resolved, req.deps.fetch ?? globalThis.fetch);
14376
+ await driveAbout(req.deps.fetch ?? globalThis.fetch, accessToken);
14377
+ return { ok: true, source: resolved.source };
14378
+ } catch (e) {
14379
+ return { ok: false, source: resolved.source, message: e instanceof Error ? e.message : String(e) };
14380
+ }
14381
+ },
14382
+ async preflight(_req) {
14383
+ return [];
14384
+ },
14385
+ async listDestinations(req) {
14386
+ const { accessToken } = await getAccessToken(req);
14387
+ const folders = await listFolders(req.deps.fetch ?? globalThis.fetch, accessToken);
14388
+ return folders.map(toDestination);
14389
+ },
14390
+ async createDestination(req, name) {
14391
+ const { accessToken } = await getAccessToken(req);
14392
+ const folder = await createFolder(req.deps.fetch ?? globalThis.fetch, accessToken, name);
14393
+ return toDestination(folder);
14394
+ },
14395
+ async upload(req) {
14396
+ const fetchImpl = req.deps.fetch ?? globalThis.fetch;
14397
+ const cfg = readConfig(req);
14398
+ const { accessToken } = await getAccessToken(req);
14399
+ const isHtml = req.artifact.format === "html";
14400
+ const source = isHtml ? await zipHtmlExport(req.artifact.path, req.project.title, (msg) => req.deps.onProgress?.(msg)) : {
14401
+ filePath: req.artifact.path,
14402
+ fileName: path25.basename(req.artifact.path),
14403
+ mimeType: "application/pdf",
14404
+ cleanup: async () => {}
14405
+ };
14406
+ try {
14407
+ const artifactStat = await stat9(source.filePath);
14408
+ const about = await driveAbout(fetchImpl, accessToken);
14409
+ if (about.quota.limitBytes != null && about.quota.freeBytes != null) {
14410
+ if (artifactStat.size > about.quota.freeBytes) {
14411
+ 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.`);
14412
+ }
14413
+ }
14414
+ req.deps.onProgress?.("Resolving the Drive folder…");
14415
+ const folder = await resolveFolder(fetchImpl, accessToken, cfg);
14416
+ const fileName = source.fileName;
14417
+ const existing = await findFileInFolder(fetchImpl, accessToken, folder.id, fileName);
14418
+ req.deps.onProgress?.(existing ? `Updating "${fileName}" in "${folder.name}"…` : `Uploading "${fileName}" to "${folder.name}"…`);
14419
+ let lastReported = -1;
14420
+ const file = await resumableUpload(fetchImpl, accessToken, {
14421
+ ...existing ? { fileId: existing.id } : {},
14422
+ name: fileName,
14423
+ parentFolderId: folder.id,
14424
+ filePath: source.filePath,
14425
+ totalBytes: artifactStat.size,
14426
+ mimeType: source.mimeType,
14427
+ onProgress: (uploaded, total) => {
14428
+ const pct = total > 0 ? Math.floor(uploaded / total * 100) : 100;
14429
+ if (pct !== lastReported) {
14430
+ lastReported = pct;
14431
+ req.deps.onProgress?.(`Uploaded ${formatBytes(uploaded)} of ${formatBytes(total)} (${pct}%)…`);
14432
+ }
14433
+ }
14434
+ });
14435
+ return {
14436
+ kind: "published",
14437
+ url: file.webViewLink,
14438
+ detail: `Uploaded "${fileName}" to the "${folder.name}" folder in your Google Drive${existing ? " (updated the existing file)" : ""}.`,
14439
+ followUp: [
14440
+ "To share it, open it in Drive and use the Share button — Gutterpress never changes who can see your files.",
14441
+ 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,
14442
+ 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.`
14443
+ ].filter((s) => !!s)
14444
+ };
14445
+ } finally {
14446
+ await source.cleanup();
14447
+ }
14448
+ }
14449
+ };
14450
+
14451
+ // src/lib/publish/butler.ts
14452
+ import { chmod as chmod2, mkdir as mkdir17, stat as stat10, writeFile as writeFile15 } from "node:fs/promises";
14453
+ import path26 from "node:path";
13625
14454
  import { unzipSync as unzipSync2 } from "fflate";
13626
14455
  var BUTLER_DOWNLOAD_TIMEOUT_MS = 300000;
13627
14456
  function butlerBrothChannel(platform2 = process.platform, arch2 = process.arch) {
@@ -13641,7 +14470,7 @@ function butlerBinaryName(platform2 = process.platform) {
13641
14470
  }
13642
14471
  async function fileExists2(p) {
13643
14472
  try {
13644
- return (await stat9(p)).isFile();
14473
+ return (await stat10(p)).isFile();
13645
14474
  } catch {
13646
14475
  return false;
13647
14476
  }
@@ -13658,8 +14487,8 @@ async function ensureButler(deps) {
13658
14487
  }
13659
14488
  if (await commandExists("butler", runCommand, deps.env))
13660
14489
  return "butler";
13661
- const cacheDir = path25.join(deps.configDir ?? defaultConfigDir(), "tools", "butler");
13662
- const cached = path25.join(cacheDir, butlerBinaryName());
14490
+ const cacheDir = path26.join(deps.configDir ?? defaultConfigDir(), "tools", "butler");
14491
+ const cached = path26.join(cacheDir, butlerBinaryName());
13663
14492
  if (await fileExists2(cached))
13664
14493
  return cached;
13665
14494
  const channel = butlerBrothChannel();
@@ -13686,9 +14515,9 @@ async function ensureButler(deps) {
13686
14515
  for (const [name, data] of Object.entries(files)) {
13687
14516
  if (name.endsWith("/"))
13688
14517
  continue;
13689
- const target = path25.join(cacheDir, path25.basename(name));
13690
- await writeFile14(target, data);
13691
- if (path25.basename(name) === butlerBinaryName()) {
14518
+ const target = path26.join(cacheDir, path26.basename(name));
14519
+ await writeFile15(target, data);
14520
+ if (path26.basename(name) === butlerBinaryName()) {
13692
14521
  await chmod2(target, 493).catch(() => {});
13693
14522
  foundBinary = true;
13694
14523
  }
@@ -13704,7 +14533,7 @@ async function ensureButler(deps) {
13704
14533
  var ITCH_HOST = "itch.io";
13705
14534
  var TARGET_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
13706
14535
  var ITCH_API_TIMEOUT_MS = 15000;
13707
- var info4 = {
14536
+ var info5 = {
13708
14537
  id: "itch",
13709
14538
  label: "itch.io",
13710
14539
  kind: "api",
@@ -13722,7 +14551,7 @@ var info4 = {
13722
14551
  hint: "Create an API key under itch.io → Settings → API keys, then paste it here."
13723
14552
  }
13724
14553
  };
13725
- function readConfig(req) {
14554
+ function readConfig2(req) {
13726
14555
  const cfg = req.config;
13727
14556
  return {
13728
14557
  target: (cfg.target ?? "").trim(),
@@ -13734,9 +14563,9 @@ function itchProjectUrl(target) {
13734
14563
  return `https://${user}.itch.io/${game}`;
13735
14564
  }
13736
14565
  var itchProvider = {
13737
- info: info4,
14566
+ info: info5,
13738
14567
  async authenticate(req) {
13739
- const resolved = await resolvePublishCredential(info4, req.deps);
14568
+ const resolved = await resolvePublishCredential(info5, req.deps);
13740
14569
  if (!resolved) {
13741
14570
  return {
13742
14571
  ok: false,
@@ -13779,7 +14608,7 @@ var itchProvider = {
13779
14608
  },
13780
14609
  async preflight(req) {
13781
14610
  const issues = [];
13782
- const { target, channel } = readConfig(req);
14611
+ const { target, channel } = readConfig2(req);
13783
14612
  if (!target) {
13784
14613
  issues.push({
13785
14614
  severity: "error",
@@ -13803,8 +14632,8 @@ var itchProvider = {
13803
14632
  return issues;
13804
14633
  },
13805
14634
  async upload(req) {
13806
- const { target, channel } = readConfig(req);
13807
- const resolved = await resolvePublishCredential(info4, req.deps);
14635
+ const { target, channel } = readConfig2(req);
14636
+ const resolved = await resolvePublishCredential(info5, req.deps);
13808
14637
  if (!resolved) {
13809
14638
  throw new Error("No itch.io API key found. Connect itch.io (or set BUTLER_API_KEY) first.");
13810
14639
  }
@@ -13832,7 +14661,7 @@ ${tail}` : ""}`);
13832
14661
 
13833
14662
  // src/lib/publish/providers/kdp.ts
13834
14663
  var KDP_URL = "https://kdp.amazon.com/en_US/bookshelf";
13835
- var info5 = {
14664
+ var info6 = {
13836
14665
  id: "kdp",
13837
14666
  label: "Amazon KDP",
13838
14667
  kind: "guided",
@@ -13842,7 +14671,7 @@ var info5 = {
13842
14671
  credential: { required: false, host: "kdp.amazon.com" }
13843
14672
  };
13844
14673
  var kdpProvider = {
13845
- info: info5,
14674
+ info: info6,
13846
14675
  async authenticate() {
13847
14676
  return { ok: true };
13848
14677
  },
@@ -13897,7 +14726,7 @@ function requireValidShop(shop) {
13897
14726
  }
13898
14727
  return shop;
13899
14728
  }
13900
- var info6 = {
14729
+ var info7 = {
13901
14730
  id: "shopify",
13902
14731
  label: "Shopify",
13903
14732
  kind: "api",
@@ -13920,7 +14749,7 @@ var info6 = {
13920
14749
  hint: "Create a custom app in Shopify admin (Settings → Apps → Develop apps) with write_products scope, then paste its Admin API access token."
13921
14750
  }
13922
14751
  };
13923
- function readConfig2(req) {
14752
+ function readConfig3(req) {
13924
14753
  const cfg = req.config;
13925
14754
  return {
13926
14755
  shop: (cfg.shop ?? "").trim().toLowerCase(),
@@ -13929,9 +14758,9 @@ function readConfig2(req) {
13929
14758
  };
13930
14759
  }
13931
14760
  async function adminGraphQL(req, query, variables = {}) {
13932
- const { shop: rawShop, apiVersion } = readConfig2(req);
14761
+ const { shop: rawShop, apiVersion } = readConfig3(req);
13933
14762
  const shop = requireValidShop(rawShop);
13934
- const resolved = await resolvePublishCredential(info6, req.deps);
14763
+ const resolved = await resolvePublishCredential(info7, req.deps);
13935
14764
  if (!resolved) {
13936
14765
  throw new Error("No Shopify access token found. Connect Shopify (or set SHOPIFY_ADMIN_TOKEN) first.");
13937
14766
  }
@@ -13982,9 +14811,9 @@ function userErrorsToMessage(result) {
13982
14811
  return errs?.length ? errs[0].message : null;
13983
14812
  }
13984
14813
  var shopifyProvider = {
13985
- info: info6,
14814
+ info: info7,
13986
14815
  async authenticate(req) {
13987
- const resolved = await resolvePublishCredential(info6, req.deps);
14816
+ const resolved = await resolvePublishCredential(info7, req.deps);
13988
14817
  if (!resolved) {
13989
14818
  return {
13990
14819
  ok: false,
@@ -14004,7 +14833,7 @@ var shopifyProvider = {
14004
14833
  },
14005
14834
  async preflight(req) {
14006
14835
  const issues = [];
14007
- const { shop } = readConfig2(req);
14836
+ const { shop } = readConfig3(req);
14008
14837
  if (!shop) {
14009
14838
  issues.push({
14010
14839
  severity: "error",
@@ -14028,13 +14857,13 @@ var shopifyProvider = {
14028
14857
  return issues;
14029
14858
  },
14030
14859
  async listProducts(req) {
14031
- const { shop } = readConfig2(req);
14860
+ const { shop } = readConfig3(req);
14032
14861
  const data = await adminGraphQL(req, `{ products(first: 25, sortKey: UPDATED_AT, reverse: true) { nodes { ${PRODUCT_FIELDS} } } }`);
14033
14862
  const nodes = data.products?.nodes ?? [];
14034
14863
  return nodes.map((n) => toProduct(shop, n));
14035
14864
  },
14036
14865
  async updateListing(req, productId, metadata) {
14037
- const { shop } = readConfig2(req);
14866
+ const { shop } = readConfig3(req);
14038
14867
  const data = await adminGraphQL(req, `mutation($product: ProductUpdateInput!) {
14039
14868
  productUpdate(product: $product) {
14040
14869
  product { ${PRODUCT_FIELDS} }
@@ -14055,7 +14884,7 @@ var shopifyProvider = {
14055
14884
  return toProduct(shop, result.product);
14056
14885
  },
14057
14886
  async upload(req) {
14058
- const { shop, productId } = readConfig2(req);
14887
+ const { shop, productId } = readConfig3(req);
14059
14888
  const title = req.project.title || "Untitled";
14060
14889
  let product;
14061
14890
  if (productId) {
@@ -14101,7 +14930,8 @@ var PROVIDERS = {
14101
14930
  drivethrurpg: drivethrurpgProvider,
14102
14931
  kdp: kdpProvider,
14103
14932
  "azure-swa": azureSwaProvider,
14104
- shopify: shopifyProvider
14933
+ shopify: shopifyProvider,
14934
+ gdrive: gdriveProvider
14105
14935
  };
14106
14936
  function listPublishProviders() {
14107
14937
  return Object.values(PROVIDERS).map((p) => p.info);
@@ -14116,6 +14946,15 @@ function publishProviderFor(id) {
14116
14946
  }
14117
14947
 
14118
14948
  // src/lib/publish/run-publish.ts
14949
+ function resolvePublishFormat(info8, providerConfig) {
14950
+ if (info8.formats && info8.formats.length > 0) {
14951
+ const configured = typeof providerConfig.format === "string" ? providerConfig.format.trim() : "";
14952
+ if (configured && info8.formats.includes(configured)) {
14953
+ return configured;
14954
+ }
14955
+ }
14956
+ return info8.format;
14957
+ }
14119
14958
  async function resolvePublishRequest(options, deps) {
14120
14959
  const provider = publishProviderFor(options.providerId);
14121
14960
  const { manifest, manifestDir } = await loadManifestWithPath(options.manifestPath ?? options.projectDir, { explicit: options.manifestPath !== undefined });
@@ -14126,10 +14965,11 @@ async function resolvePublishRequest(options, deps) {
14126
14965
  const effectiveAccount = bookAccount || deps.credentialAccount;
14127
14966
  const effectiveDeps = effectiveAccount ? { ...deps, credentialAccount: effectiveAccount } : deps;
14128
14967
  const outDir = resolveOutputDir(manifestDir, config.title);
14129
- const defaultArtifact = provider.info.format === "pdf" ? path26.join(outDir, artifactName(config.title, "pdf")) : outDir;
14968
+ const effectiveFormat = resolvePublishFormat(provider.info, providerConfig);
14969
+ const defaultArtifact = effectiveFormat === "pdf" ? path27.join(outDir, artifactName(config.title, "pdf")) : outDir;
14130
14970
  const artifact = {
14131
- path: options.artifactPath ? path26.resolve(options.projectDir, options.artifactPath) : defaultArtifact,
14132
- format: provider.info.format
14971
+ path: options.artifactPath ? path27.resolve(options.projectDir, options.artifactPath) : defaultArtifact,
14972
+ format: effectiveFormat
14133
14973
  };
14134
14974
  return {
14135
14975
  project: {
@@ -14146,7 +14986,7 @@ var PDF_HINT = "Build the PDF first (gutterpress build, or export it from the ap
14146
14986
  var HTML_HINT = "Build the website export first (gutterpress build --format html).";
14147
14987
  async function artifactIssues(artifact) {
14148
14988
  try {
14149
- const s = await stat10(artifact.path);
14989
+ const s = await stat11(artifact.path);
14150
14990
  if (artifact.format === "pdf" && !s.isFile()) {
14151
14991
  return [
14152
14992
  {
@@ -14192,7 +15032,7 @@ async function artifactIssues(artifact) {
14192
15032
  var BUILD_FINGERPRINT_FILENAME = "build-fingerprint.json";
14193
15033
  async function htmlDirIssues(dir) {
14194
15034
  const issues = [];
14195
- const isThere = async (rel) => stat10(path26.join(dir, rel)).then(() => true, () => false);
15035
+ const isThere = async (rel) => stat11(path27.join(dir, rel)).then(() => true, () => false);
14196
15036
  if (!await isThere(BOOK_HTML)) {
14197
15037
  issues.push({
14198
15038
  severity: "error",
@@ -14200,13 +15040,13 @@ async function htmlDirIssues(dir) {
14200
15040
  message: `${dir} has no ${BOOK_HTML} — it isn't an HTML export. ${HTML_HINT}`
14201
15041
  });
14202
15042
  }
14203
- const entries = await readdir11(dir).catch(() => []);
15043
+ const entries = await readdir12(dir).catch(() => []);
14204
15044
  const extras = entries.filter((name) => name.toLowerCase().endsWith(".pdf") || name === BUILD_FINGERPRINT_FILENAME);
14205
15045
  if (extras.length > 0) {
14206
15046
  issues.push({
14207
15047
  severity: "warning",
14208
15048
  id: "publish/html-dir-extras",
14209
- message: `${dir} also contains ${extras.join(" and ")} — everything in the folder is deployed and becomes publicly downloadable. ` + "Use a dedicated output folder for the website (gutterpress build --format html --out <dir>) if that isn't intended."
15049
+ 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."
14210
15050
  });
14211
15051
  }
14212
15052
  return issues;
@@ -14262,14 +15102,17 @@ function overlayStore(inner, key, candidate) {
14262
15102
  }
14263
15103
  async function connectPublishProvider(options, deps) {
14264
15104
  const provider = publishProviderFor(options.providerId);
14265
- const info7 = provider.info;
14266
- if (!info7.credential.required) {
14267
- throw new Error(`${info7.label} needs no API key — just publish when you're ready.`);
15105
+ const info8 = provider.info;
15106
+ if (!info8.credential.required) {
15107
+ throw new Error(`${info8.label} needs no API key — just publish when you're ready.`);
15108
+ }
15109
+ if (info8.credential.connect === "oauth") {
15110
+ 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.`);
14268
15111
  }
14269
15112
  const token = options.token.trim();
14270
15113
  if (!token)
14271
15114
  throw new Error("Paste an API key first.");
14272
- const host = info7.credential.host;
15115
+ const host = info8.credential.host;
14273
15116
  const account = (options.account ?? "").trim();
14274
15117
  const key = publishCredentialKey(host, account);
14275
15118
  const candidate = {
@@ -14277,12 +15120,12 @@ async function connectPublishProvider(options, deps) {
14277
15120
  kind: "token",
14278
15121
  token,
14279
15122
  ...account ? { username: account } : {},
14280
- label: account || info7.label,
15123
+ label: account || info8.label,
14281
15124
  createdAt: Date.now()
14282
15125
  };
14283
15126
  const req = await resolvePublishRequest({
14284
15127
  projectDir: options.projectDir,
14285
- providerId: info7.id,
15128
+ providerId: info8.id,
14286
15129
  ...options.manifestPath ? { manifestPath: options.manifestPath } : {}
14287
15130
  }, deps);
14288
15131
  const trialDeps = {
@@ -14293,23 +15136,32 @@ async function connectPublishProvider(options, deps) {
14293
15136
  };
14294
15137
  const auth = await provider.authenticate({ ...req, deps: trialDeps });
14295
15138
  if (!auth.ok) {
14296
- throw new Error(auth.message ?? `${info7.label} didn't accept that key.`);
15139
+ throw new Error(auth.message ?? `${info8.label} didn't accept that key.`);
14297
15140
  }
14298
15141
  await deps.tokenStore.set(key, candidate);
14299
- return { connected: true, providerId: info7.id };
15142
+ return { connected: true, providerId: info8.id };
15143
+ }
15144
+ async function disconnectPublishCredential(key, deps, options = {}) {
15145
+ const existing = await deps.tokenStore.get(key);
15146
+ await deps.tokenStore.delete(key);
15147
+ if (existing?.kind === "google-oauth") {
15148
+ const revoke = revokeGoogleCredential(existing.token, { fetchImpl: deps.fetch });
15149
+ if (options.awaitRevoke)
15150
+ await revoke;
15151
+ }
14300
15152
  }
14301
15153
  // src/lib/publish/selections.ts
14302
- import { chmod as chmod3, mkdir as mkdir18, readFile as readFile31, writeFile as writeFile15 } from "node:fs/promises";
14303
- import path27 from "node:path";
15154
+ import { chmod as chmod3, mkdir as mkdir18, readFile as readFile32, writeFile as writeFile16 } from "node:fs/promises";
15155
+ import path28 from "node:path";
14304
15156
  class PublishSelectionsStore {
14305
15157
  filePath;
14306
15158
  queue = Promise.resolve();
14307
15159
  constructor(filePath) {
14308
- this.filePath = filePath ?? path27.join(defaultConfigDir(), "publish-selections.json");
15160
+ this.filePath = filePath ?? path28.join(defaultConfigDir(), "publish-selections.json");
14309
15161
  }
14310
15162
  async read() {
14311
15163
  try {
14312
- const raw = await readFile31(this.filePath, "utf8");
15164
+ const raw = await readFile32(this.filePath, "utf8");
14313
15165
  const parsed = JSON.parse(raw);
14314
15166
  if (parsed && typeof parsed === "object") {
14315
15167
  return {
@@ -14322,8 +15174,8 @@ class PublishSelectionsStore {
14322
15174
  return { version: 1, global: {}, projects: {} };
14323
15175
  }
14324
15176
  async write(data) {
14325
- await mkdir18(path27.dirname(this.filePath), { recursive: true });
14326
- await writeFile15(this.filePath, JSON.stringify(data, null, 2), {
15177
+ await mkdir18(path28.dirname(this.filePath), { recursive: true });
15178
+ await writeFile16(this.filePath, JSON.stringify(data, null, 2), {
14327
15179
  encoding: "utf8",
14328
15180
  mode: 384
14329
15181
  });
@@ -14425,4 +15277,4 @@ async function setPublishProviderConfig(projectDir, providerId, values) {
14425
15277
  await writeManifestDoc(file, doc);
14426
15278
  return settingsFromDoc(doc);
14427
15279
  }
14428
- export { PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, MANIFEST_FILENAMES, hasProjectManifest, loadManifest, loadManifestWithPath, resolveConfig, SYNC_SNAPSHOT_MESSAGE, log, resolveActiveStyles, listProjectStyles, PLUGINS_DIR, ruleRemoteUrls, ruleRiskyProps, ruleSyntax, checkCss, runLint, formatReport, getChecks, getCheckById, resolveCheckSelectors, inspectImage, runChecks, checkToolAvailability, reportMissingTools, executeValidation, executeAndReport, splitOutPath, runBuild, openPath, startPreviewServer, defaultConfigDir, FileTokenStore, extractUrlCredential, getSystemDiagnostics, slugifyProjectName, escapeYamlScalar, scaffoldProject, adoptFolder, BUILT_IN_TEMPLATE_IDS, listBuiltInTemplates, saveProjectAsTemplate, listCustomTemplates, importTemplateFromFolder, SNIPPETS_DIR, extractVariables, substituteVariables, listSnippets, readSnippet, saveSnippet, deleteSnippet, RECOMMENDED_PLUGINS, listProjectPlugins, setPluginEnabled, addNpmPlugin, addLocalPlugin, validateProjectPlugins, THEMES_DIR, BUILT_IN_THEME_IDS, listBuiltInThemes, resolveBuiltInTheme, listProjectThemes, getActiveTheme, getPreviousTheme, revertTheme, applyTheme, importThemeFromFolder, importThemeFromUrl, readThemeCss, removeProjectTheme, importThemeFromFile, setManifestFields, readManifestFields, setActiveStyles, AUTO_SNAPSHOT_MIN_MINUTES, AUTO_SNAPSHOT_MAX_MINUTES, AUTO_SNAPSHOT_DEFAULT_MINUTES, AUTO_SYNC_MIN_MINUTES, AUTO_SYNC_MAX_MINUTES, AUTO_SYNC_DEFAULT_MINUTES, AUTO_SYNC_PUSH_INTERVAL_MINUTES, autoSnapshotDelayMs, autoSyncDelayMs, isGitInternalPath, resolveGitHubClientId, GITHUB_HOST, GitHubAuthProvider, listGitHubRepositories, listRepoBooks, listGitHubBranches, sanitizeCloneFolderName, cloneRepository, isSshRemoteUrl, testRemoteAccess, knownForgeTokenUrl, connectGenericHost, parseRemoteOrigin, forgeKindForHost, diagnoseProjectRemote, syncProject, publishCredentialKey, publishConnectionStatus, listPublishAccounts, listPublishProviders, publishProviderFor, resolvePublishRequest, runPublish, connectPublishProvider, PublishSelectionsStore, readPublishSettings, setPublishProviderConfig };
15280
+ export { PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, MANIFEST_FILENAMES, hasProjectManifest, loadManifest, loadManifestWithPath, resolveConfig, SYNC_SNAPSHOT_MESSAGE, log, resolveActiveStyles, listProjectStyles, PLUGINS_DIR, ruleRemoteUrls, ruleRiskyProps, ruleSyntax, checkCss, runLint, formatReport, getChecks, getCheckById, resolveCheckSelectors, inspectImage, runChecks, checkToolAvailability, reportMissingTools, executeValidation, executeAndReport, splitOutPath, runBuild, openPath, startPreviewServer, defaultConfigDir, FileTokenStore, extractUrlCredential, getSystemDiagnostics, slugifyProjectName, escapeYamlScalar, scaffoldProject, adoptFolder, BUILT_IN_TEMPLATE_IDS, listBuiltInTemplates, saveProjectAsTemplate, listCustomTemplates, importTemplateFromFolder, SNIPPETS_DIR, extractVariables, substituteVariables, listSnippets, readSnippet, saveSnippet, deleteSnippet, RECOMMENDED_PLUGINS, listProjectPlugins, setPluginEnabled, addNpmPlugin, addLocalPlugin, validateProjectPlugins, THEMES_DIR, BUILT_IN_THEME_IDS, listBuiltInThemes, resolveBuiltInTheme, listProjectThemes, getActiveTheme, getPreviousTheme, revertTheme, applyTheme, importThemeFromFolder, importThemeFromUrl, readThemeCss, removeProjectTheme, importThemeFromFile, setManifestFields, readManifestFields, setActiveStyles, AUTO_SNAPSHOT_MIN_MINUTES, AUTO_SNAPSHOT_MAX_MINUTES, AUTO_SNAPSHOT_DEFAULT_MINUTES, AUTO_SYNC_MIN_MINUTES, AUTO_SYNC_MAX_MINUTES, AUTO_SYNC_DEFAULT_MINUTES, AUTO_SYNC_PUSH_INTERVAL_MINUTES, autoSnapshotDelayMs, autoSyncDelayMs, isGitInternalPath, resolveGitHubClientId, GITHUB_HOST, GitHubAuthProvider, GDRIVE_HOST, resolveGoogleClientId, resolveGoogleClientSecret, GOOGLE_NOT_CONFIGURED_MESSAGE, requireGoogleClientCredentials, pkceChallengeFromVerifier, GoogleAuthProvider, revokeGoogleCredential, publishCredentialKey, publishConnectionStatus, listPublishAccounts, connectGoogleDrive, listGitHubRepositories, listRepoBooks, listGitHubBranches, sanitizeCloneFolderName, cloneRepository, isSshRemoteUrl, testRemoteAccess, knownForgeTokenUrl, connectGenericHost, parseRemoteOrigin, forgeKindForHost, diagnoseProjectRemote, syncProject, listPublishProviders, publishProviderFor, resolvePublishFormat, resolvePublishRequest, runPublish, connectPublishProvider, disconnectPublishCredential, PublishSelectionsStore, readPublishSettings, setPublishProviderConfig };