@gamerdexapp/gamerdex-sync 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-10-18
11
+
12
+ ### Fixed
13
+
14
+ - `GoogleDriveProvider` no longer fails when the Drive search does not list a file or folder that it
15
+ has just created. Drive can lag behind a create, so the provider now counts the new item with the
16
+ creation time that Drive returns.
17
+
18
+ ## [0.2.0] - 2026-10-17
19
+
20
+ ### Added
21
+
22
+ - `GoogleDriveProvider` keeps the files in the Google Drive of the player, in a visible `Gamerdex`
23
+ folder, with the narrow `drive.file` scope. Drive cannot refuse a write made from an old version,
24
+ so the provider checks the head revision before the upload and the revision list after it. When
25
+ another device wrote in between, its content is put back and the sync merges both sides.
26
+ - `googleAuthorizeUrl()` and `GOOGLE_DRIVE_SCOPE` for the Google sign in page.
27
+ - `ProviderError.detail` also reads the message of a failed Google request.
28
+
10
29
  ## [0.1.1] - 2026-10-17
11
30
 
12
31
  ### Added
package/dist/index.d.mts CHANGED
@@ -393,4 +393,54 @@ interface InstalledRepository {
393
393
  */
394
394
  declare const listInstalledRepositories: (token: string, send?: typeof fetch) => Promise<InstalledRepository[]>;
395
395
 
396
- export { type CallbackResult, type Conflict, type ConflictKind, DATA_FILE, type DeviceRecord, type FirstSyncChoice, GITHUB_APP, type GitHubOptions, GitHubProvider, type InstalledRepository, META_FILE, META_FORMAT, MemoryProvider, type MergeInput, type MergeResult, type ParsedMeta, type Path, type PathStep, ProviderError, type ProviderErrorKind, ROOT_FOLDER, RemoteDataError, type RemoteFile, type RepositoryCheck, type Schema, type Side, type StoredTokens, SyncBusyError, type SyncMeta, type SyncOptions, type SyncProvider, type SyncResult, type SyncState, type TokenResponse, type TokenSourceOptions, VersionConflictError, authorizeUrl, createTokenSource, dataPath, decodeBase64, emptyMeta, encodeBase64, equal, installUrl, isAppId, isObject, listInstalledRepositories, merge, metaPath, newState, parseMeta, parseTokenResponse, readCallback, resolveConflict, restoreCloudValues, sync, toStoredTokens, withChiefRemoved, withChiefRevision, withDevice, withSettingsRevision };
396
+ interface GoogleDriveOptions {
397
+ /** Returns the token. It may refresh the token first, so it is called for every request. */
398
+ getToken: () => Promise<string>;
399
+ fetch?: typeof fetch;
400
+ }
401
+ /**
402
+ * Keeps the files of an app in the Google Drive of the player, in a visible `Gamerdex` folder. It
403
+ * needs only the `drive.file` scope, which sees the files that this app created.
404
+ *
405
+ * Drive cannot refuse a write made from an old version. Writes are checked in two steps instead:
406
+ * the head revision must equal the expected version before the upload, and the revision list must
407
+ * show the new revision directly on top of the expected one after it. When another device wrote in
408
+ * between, its content is written back as the head and `VersionConflictError` is thrown, so the
409
+ * next sync merges both sides.
410
+ */
411
+ declare class GoogleDriveProvider implements SyncProvider {
412
+ private readonly options;
413
+ private readonly send;
414
+ private readonly folders;
415
+ constructor(options: GoogleDriveOptions);
416
+ read(path: string): Promise<RemoteFile | null>;
417
+ write(path: string, content: string, expectedVersion: string | null): Promise<{
418
+ version: string;
419
+ }>;
420
+ remove(path: string, expectedVersion: string): Promise<void>;
421
+ private create;
422
+ private findFile;
423
+ private locate;
424
+ private folderId;
425
+ private children;
426
+ private headOf;
427
+ private revisionContent;
428
+ private revisionBefore;
429
+ private upload;
430
+ private createFile;
431
+ private deleteItem;
432
+ private itemOf;
433
+ private stringField;
434
+ private request;
435
+ private failure;
436
+ }
437
+
438
+ /** The only Drive scope that sync asks for. It sees the files that the app created itself. */
439
+ declare const GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
440
+ /**
441
+ * The address of the Google sign in page. `access_type=offline` and `prompt=consent` make Google
442
+ * send a refresh token, which it otherwise sends only the first time the player agrees.
443
+ */
444
+ declare const googleAuthorizeUrl: (clientId: string, redirectUri: string, state: string) => string;
445
+
446
+ export { type CallbackResult, type Conflict, type ConflictKind, DATA_FILE, type DeviceRecord, type FirstSyncChoice, GITHUB_APP, GOOGLE_DRIVE_SCOPE, type GitHubOptions, GitHubProvider, type GoogleDriveOptions, GoogleDriveProvider, type InstalledRepository, META_FILE, META_FORMAT, MemoryProvider, type MergeInput, type MergeResult, type ParsedMeta, type Path, type PathStep, ProviderError, type ProviderErrorKind, ROOT_FOLDER, RemoteDataError, type RemoteFile, type RepositoryCheck, type Schema, type Side, type StoredTokens, SyncBusyError, type SyncMeta, type SyncOptions, type SyncProvider, type SyncResult, type SyncState, type TokenResponse, type TokenSourceOptions, VersionConflictError, authorizeUrl, createTokenSource, dataPath, decodeBase64, emptyMeta, encodeBase64, equal, googleAuthorizeUrl, installUrl, isAppId, isObject, listInstalledRepositories, merge, metaPath, newState, parseMeta, parseTokenResponse, readCallback, resolveConflict, restoreCloudValues, sync, toStoredTokens, withChiefRemoved, withChiefRevision, withDevice, withSettingsRevision };
package/dist/index.d.ts CHANGED
@@ -393,4 +393,54 @@ interface InstalledRepository {
393
393
  */
394
394
  declare const listInstalledRepositories: (token: string, send?: typeof fetch) => Promise<InstalledRepository[]>;
395
395
 
396
- export { type CallbackResult, type Conflict, type ConflictKind, DATA_FILE, type DeviceRecord, type FirstSyncChoice, GITHUB_APP, type GitHubOptions, GitHubProvider, type InstalledRepository, META_FILE, META_FORMAT, MemoryProvider, type MergeInput, type MergeResult, type ParsedMeta, type Path, type PathStep, ProviderError, type ProviderErrorKind, ROOT_FOLDER, RemoteDataError, type RemoteFile, type RepositoryCheck, type Schema, type Side, type StoredTokens, SyncBusyError, type SyncMeta, type SyncOptions, type SyncProvider, type SyncResult, type SyncState, type TokenResponse, type TokenSourceOptions, VersionConflictError, authorizeUrl, createTokenSource, dataPath, decodeBase64, emptyMeta, encodeBase64, equal, installUrl, isAppId, isObject, listInstalledRepositories, merge, metaPath, newState, parseMeta, parseTokenResponse, readCallback, resolveConflict, restoreCloudValues, sync, toStoredTokens, withChiefRemoved, withChiefRevision, withDevice, withSettingsRevision };
396
+ interface GoogleDriveOptions {
397
+ /** Returns the token. It may refresh the token first, so it is called for every request. */
398
+ getToken: () => Promise<string>;
399
+ fetch?: typeof fetch;
400
+ }
401
+ /**
402
+ * Keeps the files of an app in the Google Drive of the player, in a visible `Gamerdex` folder. It
403
+ * needs only the `drive.file` scope, which sees the files that this app created.
404
+ *
405
+ * Drive cannot refuse a write made from an old version. Writes are checked in two steps instead:
406
+ * the head revision must equal the expected version before the upload, and the revision list must
407
+ * show the new revision directly on top of the expected one after it. When another device wrote in
408
+ * between, its content is written back as the head and `VersionConflictError` is thrown, so the
409
+ * next sync merges both sides.
410
+ */
411
+ declare class GoogleDriveProvider implements SyncProvider {
412
+ private readonly options;
413
+ private readonly send;
414
+ private readonly folders;
415
+ constructor(options: GoogleDriveOptions);
416
+ read(path: string): Promise<RemoteFile | null>;
417
+ write(path: string, content: string, expectedVersion: string | null): Promise<{
418
+ version: string;
419
+ }>;
420
+ remove(path: string, expectedVersion: string): Promise<void>;
421
+ private create;
422
+ private findFile;
423
+ private locate;
424
+ private folderId;
425
+ private children;
426
+ private headOf;
427
+ private revisionContent;
428
+ private revisionBefore;
429
+ private upload;
430
+ private createFile;
431
+ private deleteItem;
432
+ private itemOf;
433
+ private stringField;
434
+ private request;
435
+ private failure;
436
+ }
437
+
438
+ /** The only Drive scope that sync asks for. It sees the files that the app created itself. */
439
+ declare const GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
440
+ /**
441
+ * The address of the Google sign in page. `access_type=offline` and `prompt=consent` make Google
442
+ * send a refresh token, which it otherwise sends only the first time the player agrees.
443
+ */
444
+ declare const googleAuthorizeUrl: (clientId: string, redirectUri: string, state: string) => string;
445
+
446
+ export { type CallbackResult, type Conflict, type ConflictKind, DATA_FILE, type DeviceRecord, type FirstSyncChoice, GITHUB_APP, GOOGLE_DRIVE_SCOPE, type GitHubOptions, GitHubProvider, type GoogleDriveOptions, GoogleDriveProvider, type InstalledRepository, META_FILE, META_FORMAT, MemoryProvider, type MergeInput, type MergeResult, type ParsedMeta, type Path, type PathStep, ProviderError, type ProviderErrorKind, ROOT_FOLDER, RemoteDataError, type RemoteFile, type RepositoryCheck, type Schema, type Side, type StoredTokens, SyncBusyError, type SyncMeta, type SyncOptions, type SyncProvider, type SyncResult, type SyncState, type TokenResponse, type TokenSourceOptions, VersionConflictError, authorizeUrl, createTokenSource, dataPath, decodeBase64, emptyMeta, encodeBase64, equal, googleAuthorizeUrl, installUrl, isAppId, isObject, listInstalledRepositories, merge, metaPath, newState, parseMeta, parseTokenResponse, readCallback, resolveConflict, restoreCloudValues, sync, toStoredTokens, withChiefRemoved, withChiefRevision, withDevice, withSettingsRevision };
package/dist/index.js CHANGED
@@ -22,7 +22,9 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DATA_FILE: () => DATA_FILE,
24
24
  GITHUB_APP: () => GITHUB_APP,
25
+ GOOGLE_DRIVE_SCOPE: () => GOOGLE_DRIVE_SCOPE,
25
26
  GitHubProvider: () => GitHubProvider,
27
+ GoogleDriveProvider: () => GoogleDriveProvider,
26
28
  META_FILE: () => META_FILE,
27
29
  META_FORMAT: () => META_FORMAT,
28
30
  MemoryProvider: () => MemoryProvider,
@@ -38,6 +40,7 @@ __export(index_exports, {
38
40
  emptyMeta: () => emptyMeta,
39
41
  encodeBase64: () => encodeBase64,
40
42
  equal: () => equal,
43
+ googleAuthorizeUrl: () => googleAuthorizeUrl,
41
44
  installUrl: () => installUrl,
42
45
  isAppId: () => isAppId,
43
46
  isObject: () => isObject,
@@ -348,10 +351,11 @@ var decodeBase64 = (base64) => {
348
351
 
349
352
  // src/provider/detail.ts
350
353
  var MAX_DETAIL_LENGTH = 300;
354
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
351
355
  var detailOf = async (response) => {
352
356
  const body = await response.json().catch(() => null);
353
- if (typeof body !== "object" || body === null) return void 0;
354
- const message = body.message;
357
+ if (!isRecord2(body)) return void 0;
358
+ const message = isRecord2(body.error) ? body.error.message : body.message;
355
359
  return typeof message === "string" ? message.slice(0, MAX_DETAIL_LENGTH) : void 0;
356
360
  };
357
361
 
@@ -383,7 +387,7 @@ var ProviderError = class extends Error {
383
387
  var API = "https://api.github.com";
384
388
  var API_VERSION = "2022-11-28";
385
389
  var encodePath = (path) => path.split("/").map(encodeURIComponent).join("/");
386
- var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
390
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
387
391
  var retryAfterOf = (response) => {
388
392
  const header = response.headers.get("retry-after");
389
393
  if (header !== null && Number.isFinite(Number(header))) return Number(header);
@@ -449,10 +453,10 @@ var GitHubProvider = class {
449
453
  const response = await this.request("GET", `${API}/repos/${this.options.repository}`);
450
454
  await this.assertOk(response);
451
455
  const body = await response.json();
452
- if (!isRecord2(body) || typeof body.private !== "boolean") {
456
+ if (!isRecord3(body) || typeof body.private !== "boolean") {
453
457
  throw this.invalid("The repository answer is not understood.");
454
458
  }
455
- const permissions = isRecord2(body.permissions) ? body.permissions : {};
459
+ const permissions = isRecord3(body.permissions) ? body.permissions : {};
456
460
  return {
457
461
  private: body.private,
458
462
  canWrite: permissions.push === true,
@@ -682,10 +686,10 @@ var GITHUB_APP = {
682
686
  };
683
687
 
684
688
  // src/auth/tokens.ts
685
- var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
689
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
686
690
  var isSeconds = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
687
691
  var parseTokenResponse = (body) => {
688
- if (!isRecord3(body)) return null;
692
+ if (!isRecord4(body)) return null;
689
693
  const { accessToken, expiresIn, refreshToken, refreshExpiresIn } = body;
690
694
  if (typeof accessToken !== "string" || accessToken === "") return null;
691
695
  if (typeof refreshToken !== "string" || refreshToken === "") return null;
@@ -775,7 +779,7 @@ var readCallback = (search, expectedState) => {
775
779
  // src/provider/repositories.ts
776
780
  var API2 = "https://api.github.com";
777
781
  var PAGE_SIZE = 100;
778
- var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
782
+ var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
779
783
  var getJson = async (send, token, url) => {
780
784
  let response;
781
785
  try {
@@ -806,7 +810,7 @@ var getJson = async (send, token, url) => {
806
810
  );
807
811
  }
808
812
  const body = await response.json().catch(() => null);
809
- if (!isRecord4(body)) {
813
+ if (!isRecord5(body)) {
810
814
  throw new ProviderError("invalid", "GitHub answer is not understood.", response.status);
811
815
  }
812
816
  return body;
@@ -817,7 +821,7 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
817
821
  token,
818
822
  `${API2}/user/installations?per_page=${PAGE_SIZE}`
819
823
  );
820
- const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord4).map((installation) => installation.id).filter((id) => typeof id === "number");
824
+ const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord5).map((installation) => installation.id).filter((id) => typeof id === "number");
821
825
  const repositories = [];
822
826
  for (const id of ids) {
823
827
  const page = await getJson(
@@ -826,18 +830,287 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
826
830
  `${API2}/user/installations/${id}/repositories?per_page=${PAGE_SIZE}`
827
831
  );
828
832
  for (const item of Array.isArray(page.repositories) ? page.repositories : []) {
829
- if (isRecord4(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
833
+ if (isRecord5(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
830
834
  repositories.push({ fullName: item.full_name, private: item.private });
831
835
  }
832
836
  }
833
837
  }
834
838
  return repositories;
835
839
  };
840
+
841
+ // src/provider/drive.ts
842
+ var API3 = "https://www.googleapis.com/drive/v3";
843
+ var UPLOAD = "https://www.googleapis.com/upload/drive/v3";
844
+ var FOLDER_TYPE = "application/vnd.google-apps.folder";
845
+ var RATE_LIMIT_REASONS = ["rateLimitExceeded", "userRateLimitExceeded", "dailyLimitExceeded"];
846
+ var isRecord6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
847
+ var quoted = (value) => `'${value.replace(/[\\']/g, "\\$&")}'`;
848
+ var oldestFirst = (a, b) => a.createdTime.localeCompare(b.createdTime) || a.id.localeCompare(b.id);
849
+ var withOwn = (found, own4) => (found.some((item) => item.id === own4.id) ? found : [...found, own4]).sort(oldestFirst);
850
+ var reasonOf = (body) => {
851
+ if (!isRecord6(body) || !isRecord6(body.error) || !Array.isArray(body.error.errors))
852
+ return void 0;
853
+ const first = body.error.errors[0];
854
+ return isRecord6(first) && typeof first.reason === "string" ? first.reason : void 0;
855
+ };
856
+ var GoogleDriveProvider = class {
857
+ constructor(options) {
858
+ this.options = options;
859
+ this.send = options.fetch ?? ((...args) => globalThis.fetch(...args));
860
+ }
861
+ options;
862
+ send;
863
+ folders = /* @__PURE__ */ new Map();
864
+ async read(path) {
865
+ const file = await this.findFile(path);
866
+ if (!file) return null;
867
+ const head = await this.headOf(file.id);
868
+ if (head === null) return null;
869
+ return { content: await this.revisionContent(file.id, head), version: head };
870
+ }
871
+ async write(path, content, expectedVersion) {
872
+ if (expectedVersion === null) return this.create(path, content);
873
+ const file = await this.findFile(path);
874
+ if (!file || await this.headOf(file.id) !== expectedVersion) {
875
+ throw new VersionConflictError(path);
876
+ }
877
+ const version = await this.upload(file.id, content);
878
+ if (version === expectedVersion) return { version };
879
+ const previous = await this.revisionBefore(file.id, version);
880
+ if (previous === expectedVersion) return { version };
881
+ await this.upload(file.id, await this.revisionContent(file.id, previous));
882
+ throw new VersionConflictError(path);
883
+ }
884
+ async remove(path, expectedVersion) {
885
+ const file = await this.findFile(path);
886
+ if (!file) return;
887
+ if (await this.headOf(file.id) !== expectedVersion) throw new VersionConflictError(path);
888
+ await this.deleteItem(file.id);
889
+ }
890
+ async create(path, content) {
891
+ const { parentId, name } = await this.locate(path, true);
892
+ if ((await this.children(parentId, name)).length > 0) throw new VersionConflictError(path);
893
+ const created = await this.createFile(parentId, name, content);
894
+ const [oldest] = withOwn(await this.children(parentId, name), created);
895
+ if (oldest.id !== created.id) {
896
+ await this.deleteItem(created.id);
897
+ throw new VersionConflictError(path);
898
+ }
899
+ return { version: created.version };
900
+ }
901
+ async findFile(path) {
902
+ const located = await this.locate(path, false);
903
+ if (!located) return null;
904
+ return (await this.children(located.parentId, located.name))[0] ?? null;
905
+ }
906
+ async locate(path, create) {
907
+ const parts = path.split("/");
908
+ const name = parts.pop();
909
+ let parentId = "root";
910
+ let walked = "";
911
+ for (const folder of parts) {
912
+ walked += `/${folder}`;
913
+ const known = this.folders.get(walked);
914
+ if (known) {
915
+ parentId = known;
916
+ continue;
917
+ }
918
+ const id = await this.folderId(parentId, folder, create);
919
+ if (id === null) return null;
920
+ this.folders.set(walked, id);
921
+ parentId = id;
922
+ }
923
+ return { parentId, name };
924
+ }
925
+ async folderId(parentId, name, create) {
926
+ const found = await this.children(parentId, name, true);
927
+ if (found.length > 0) return found[0].id;
928
+ if (!create) return null;
929
+ const response = await this.request("POST", `${API3}/files?fields=id,createdTime`, {
930
+ body: JSON.stringify({ name, mimeType: FOLDER_TYPE, parents: [parentId] }),
931
+ headers: { "Content-Type": "application/json" }
932
+ });
933
+ const created = this.itemOf(await response.json());
934
+ const [oldest] = withOwn(await this.children(parentId, name, true), created);
935
+ if (oldest.id !== created.id) {
936
+ await this.deleteItem(created.id);
937
+ return oldest.id;
938
+ }
939
+ return created.id;
940
+ }
941
+ async children(parentId, name, folder = false) {
942
+ const query = [
943
+ `name = ${quoted(name)}`,
944
+ `${quoted(parentId)} in parents`,
945
+ "trashed = false",
946
+ `mimeType ${folder ? "=" : "!="} '${FOLDER_TYPE}'`
947
+ ].join(" and ");
948
+ const url = `${API3}/files?${new URLSearchParams({
949
+ q: query,
950
+ fields: "files(id,createdTime)",
951
+ pageSize: "100"
952
+ })}`;
953
+ const body = await (await this.request("GET", url)).json();
954
+ const files = isRecord6(body) && Array.isArray(body.files) ? body.files : [];
955
+ return files.filter(
956
+ (item) => isRecord6(item) && typeof item.id === "string" && typeof item.createdTime === "string"
957
+ ).sort(oldestFirst);
958
+ }
959
+ async headOf(id) {
960
+ const response = await this.request(
961
+ "GET",
962
+ `${API3}/files/${encodeURIComponent(id)}?fields=headRevisionId,trashed`,
963
+ { allowMissing: true }
964
+ );
965
+ if (response.status === 404) return null;
966
+ const body = await response.json();
967
+ if (isRecord6(body) && body.trashed === true) return null;
968
+ return this.stringField(body, "headRevisionId");
969
+ }
970
+ async revisionContent(id, revision) {
971
+ const url = `${API3}/files/${encodeURIComponent(id)}/revisions/${encodeURIComponent(revision)}?alt=media`;
972
+ return (await this.request("GET", url)).text();
973
+ }
974
+ async revisionBefore(id, revision) {
975
+ const url = `${API3}/files/${encodeURIComponent(id)}/revisions?${new URLSearchParams({
976
+ fields: "revisions(id)",
977
+ pageSize: "1000"
978
+ })}`;
979
+ const body = await (await this.request("GET", url)).json();
980
+ const revisions = (isRecord6(body) && Array.isArray(body.revisions) ? body.revisions : []).filter(isRecord6).map((item) => item.id);
981
+ const index = revisions.indexOf(revision);
982
+ const previous = revisions[index - 1];
983
+ if (index < 1 || typeof previous !== "string") {
984
+ throw new ProviderError("server", "The new revision is not in the revision list.");
985
+ }
986
+ return previous;
987
+ }
988
+ async upload(id, content) {
989
+ const url = `${UPLOAD}/files/${encodeURIComponent(id)}?uploadType=media&fields=headRevisionId`;
990
+ const response = await this.request("PATCH", url, {
991
+ body: content,
992
+ headers: { "Content-Type": "application/json" }
993
+ });
994
+ return this.stringField(await response.json(), "headRevisionId");
995
+ }
996
+ async createFile(parentId, name, content) {
997
+ const boundary = `gamerdex-${crypto.randomUUID()}`;
998
+ const metadata = JSON.stringify({ name, parents: [parentId], mimeType: "application/json" });
999
+ const body = `--${boundary}\r
1000
+ Content-Type: application/json; charset=UTF-8\r
1001
+ \r
1002
+ ${metadata}\r
1003
+ --${boundary}\r
1004
+ Content-Type: application/json\r
1005
+ \r
1006
+ ${content}\r
1007
+ --${boundary}--`;
1008
+ const response = await this.request(
1009
+ "POST",
1010
+ `${UPLOAD}/files?uploadType=multipart&fields=id,headRevisionId,createdTime`,
1011
+ { body, headers: { "Content-Type": `multipart/related; boundary=${boundary}` } }
1012
+ );
1013
+ const answer = await response.json();
1014
+ return { ...this.itemOf(answer), version: this.stringField(answer, "headRevisionId") };
1015
+ }
1016
+ async deleteItem(id) {
1017
+ await this.request("DELETE", `${API3}/files/${encodeURIComponent(id)}`, { allowMissing: true });
1018
+ }
1019
+ itemOf(body) {
1020
+ return { id: this.stringField(body, "id"), createdTime: this.stringField(body, "createdTime") };
1021
+ }
1022
+ stringField(body, field) {
1023
+ const value = isRecord6(body) ? body[field] : void 0;
1024
+ if (typeof value !== "string") {
1025
+ throw new ProviderError("invalid", `Google Drive answered without ${field}.`);
1026
+ }
1027
+ return value;
1028
+ }
1029
+ async request(method, url, options = {}) {
1030
+ const token = await this.options.getToken();
1031
+ let response;
1032
+ try {
1033
+ response = await this.send(url, {
1034
+ method,
1035
+ headers: { Authorization: `Bearer ${token}`, ...options.headers ?? {} },
1036
+ body: options.body
1037
+ });
1038
+ } catch {
1039
+ throw new ProviderError("network", "Google Drive could not be reached.");
1040
+ }
1041
+ if (response.ok || options.allowMissing && response.status === 404) return response;
1042
+ throw await this.failure(response);
1043
+ }
1044
+ async failure(response) {
1045
+ const { status } = response;
1046
+ const body = await response.clone().json().catch(() => null);
1047
+ const detail = await detailOf(response);
1048
+ if (status === 401) {
1049
+ return new ProviderError("auth", "The token is not valid.", status, void 0, detail);
1050
+ }
1051
+ const reason = reasonOf(body);
1052
+ if (status === 429 || status === 403 && reason && RATE_LIMIT_REASONS.includes(reason)) {
1053
+ const retryAfter = Number(response.headers.get("retry-after"));
1054
+ return new ProviderError(
1055
+ "rate-limit",
1056
+ "Google Drive limits the calls.",
1057
+ status,
1058
+ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0,
1059
+ detail
1060
+ );
1061
+ }
1062
+ if (status === 403) {
1063
+ return new ProviderError(
1064
+ "auth",
1065
+ "The token has no permission for Drive.",
1066
+ status,
1067
+ void 0,
1068
+ detail
1069
+ );
1070
+ }
1071
+ if (status === 404) {
1072
+ return new ProviderError(
1073
+ "not-found",
1074
+ "The file or folder was not found.",
1075
+ status,
1076
+ void 0,
1077
+ detail
1078
+ );
1079
+ }
1080
+ if (status >= 500) {
1081
+ return new ProviderError("server", "Google Drive failed.", status, void 0, detail);
1082
+ }
1083
+ return new ProviderError(
1084
+ "invalid",
1085
+ `Google Drive answered ${status}.`,
1086
+ status,
1087
+ void 0,
1088
+ detail
1089
+ );
1090
+ }
1091
+ };
1092
+
1093
+ // src/auth/google.ts
1094
+ var GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
1095
+ var googleAuthorizeUrl = (clientId, redirectUri, state) => {
1096
+ const query = new URLSearchParams({
1097
+ client_id: clientId,
1098
+ redirect_uri: redirectUri,
1099
+ response_type: "code",
1100
+ scope: GOOGLE_DRIVE_SCOPE,
1101
+ access_type: "offline",
1102
+ prompt: "consent",
1103
+ state
1104
+ });
1105
+ return `https://accounts.google.com/o/oauth2/v2/auth?${query}`;
1106
+ };
836
1107
  // Annotate the CommonJS export names for ESM import in node:
837
1108
  0 && (module.exports = {
838
1109
  DATA_FILE,
839
1110
  GITHUB_APP,
1111
+ GOOGLE_DRIVE_SCOPE,
840
1112
  GitHubProvider,
1113
+ GoogleDriveProvider,
841
1114
  META_FILE,
842
1115
  META_FORMAT,
843
1116
  MemoryProvider,
@@ -853,6 +1126,7 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
853
1126
  emptyMeta,
854
1127
  encodeBase64,
855
1128
  equal,
1129
+ googleAuthorizeUrl,
856
1130
  installUrl,
857
1131
  isAppId,
858
1132
  isObject,