@drop2run/mcp 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/index.js +497 -92
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,7 +88,10 @@ lose it.
|
|
|
88
88
|
| `list_folders` | Lists the account's folders, each with the path `folder` accepts |
|
|
89
89
|
| `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
|
|
90
90
|
| `get_site` | Reads a site's settings, whether it is paused, and its versions with the live one marked |
|
|
91
|
-
| `update_site` | Changes a site's name, serving mode, password, forms, scheduled takedown or folder |
|
|
91
|
+
| `update_site` | Changes a site's name, serving mode, password, invite-only, comments, forms, scheduled takedown or folder |
|
|
92
|
+
| `list_people` | Lists who a site is shared with, what each may do, and whether only they can open it |
|
|
93
|
+
| `invite_people` | Invites addresses the person gave — to view, or to view and comment — and emails each one |
|
|
94
|
+
| `remove_person` | Takes one address off a site's list |
|
|
92
95
|
| `pause_site` | Takes a site off the air, keeping its files, versions and subdomain |
|
|
93
96
|
| `resume_site` | Puts a paused site back on the air |
|
|
94
97
|
| `rollback_site` | Serves an earlier version again, by its id from `get_site` |
|
|
@@ -117,6 +120,12 @@ default; `expiryAction: "delete"` removes it instead, and asks for the subdomain
|
|
|
117
120
|
`delete_site` does. Settings your plan does not include are refused with the reason, and `get_site` says
|
|
118
121
|
which ones those are.
|
|
119
122
|
|
|
123
|
+
To share a site for review, `invite_people` puts the addresses the person gave on its list and emails each
|
|
124
|
+
one; `update_site` with `invitedOnly: true` makes the list the only way in, and `comments` decides who may
|
|
125
|
+
comment — `invited` for the people invited with the comment role, `anyone` for anybody signed in. Both are
|
|
126
|
+
plan features, refused with the reason where the plan lacks them. The tool description tells the model never
|
|
127
|
+
to invite an address the person did not give it.
|
|
128
|
+
|
|
120
129
|
The comment tools close the loop on a site somebody is reviewing: `list_comments` gives each thread's page
|
|
121
130
|
and the words or element it points at, so the source can be found; after the fix is published,
|
|
122
131
|
`reply_comment` answers on the page. Publish first and reply second, or the commenter opens the old
|
package/dist/index.js
CHANGED
|
@@ -177,10 +177,7 @@ async function folderFromListing(credentials, siteId, subdomain) {
|
|
|
177
177
|
}
|
|
178
178
|
async function getSite(credentials, siteId) {
|
|
179
179
|
const [site, invitedOnly] = await Promise.all([
|
|
180
|
-
call(
|
|
181
|
-
credentials,
|
|
182
|
-
`sites/${encodeURIComponent(siteId)}`
|
|
183
|
-
),
|
|
180
|
+
call(credentials, `sites/${encodeURIComponent(siteId)}`),
|
|
184
181
|
readInvitedOnly(credentials, siteId)
|
|
185
182
|
]);
|
|
186
183
|
const folderId = site.folderId !== void 0 ? site.folderId : await folderFromListing(credentials, site.siteId, site.subdomain);
|
|
@@ -202,6 +199,8 @@ async function getSite(credentials, siteId) {
|
|
|
202
199
|
expiresAt: site.expiresAt,
|
|
203
200
|
expiryAction: actionOf(site.expiresAt, site.expiryAction ?? ""),
|
|
204
201
|
scheduledExpiryAvailable: site.scheduledExpiryAvailable,
|
|
202
|
+
comments: audienceOf(site.commentAudience),
|
|
203
|
+
commentsAvailable: site.feedbackAvailable === true,
|
|
205
204
|
folderId,
|
|
206
205
|
deploys: site.deploys.map((deploy2) => ({
|
|
207
206
|
deployId: deploy2.deployId,
|
|
@@ -226,9 +225,11 @@ async function updateSiteSettings(credentials, siteId, change) {
|
|
|
226
225
|
if (change.expiryAction !== void 0) body.expiryAction = change.expiryAction;
|
|
227
226
|
if (change.formsEnabled !== void 0) body.formsEnabled = change.formsEnabled;
|
|
228
227
|
if (change.folderId !== void 0) body.folderId = change.folderId;
|
|
228
|
+
if (change.invitedOnly !== void 0) body.invitedOnly = change.invitedOnly;
|
|
229
|
+
if (change.comments !== void 0) body.commentAudience = API_AUDIENCE[change.comments];
|
|
229
230
|
if (Object.keys(body).length === 0) throw new Error("Nothing to change — name a setting.");
|
|
230
231
|
const wasInvitedOnly = change.password !== void 0 && change.password !== "" ? await readInvitedOnly(credentials, siteId) : null;
|
|
231
|
-
const settings = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
|
|
232
|
+
const { commentAudience, ...settings } = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
|
|
232
233
|
method: "PATCH",
|
|
233
234
|
body: JSON.stringify(body)
|
|
234
235
|
});
|
|
@@ -237,9 +238,19 @@ async function updateSiteSettings(credentials, siteId, change) {
|
|
|
237
238
|
...settings,
|
|
238
239
|
expiryAction: actionOf(settings.expiresAt, settings.expiryAction ?? ""),
|
|
239
240
|
invitedOnly,
|
|
241
|
+
comments: audienceOf(commentAudience),
|
|
240
242
|
replacedInviteOnly: wasInvitedOnly === true && invitedOnly === false
|
|
241
243
|
};
|
|
242
244
|
}
|
|
245
|
+
const API_AUDIENCE = {
|
|
246
|
+
off: "Off",
|
|
247
|
+
invited: "Invited",
|
|
248
|
+
anyone: "Anyone"
|
|
249
|
+
};
|
|
250
|
+
function audienceOf(value) {
|
|
251
|
+
const lower = value?.toLowerCase();
|
|
252
|
+
return lower === "off" || lower === "invited" || lower === "anyone" ? lower : null;
|
|
253
|
+
}
|
|
243
254
|
async function setSitePaused(credentials, siteId, action) {
|
|
244
255
|
const state = await call(
|
|
245
256
|
credentials,
|
|
@@ -354,6 +365,34 @@ async function setCommentResolved(credentials, siteId, threadId, resolved) {
|
|
|
354
365
|
{ method: "POST" }
|
|
355
366
|
);
|
|
356
367
|
}
|
|
368
|
+
const PEOPLE_PER_REQUEST = 50;
|
|
369
|
+
async function listPeople(credentials, siteId) {
|
|
370
|
+
const page = await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers?pageSize=${PEOPLE_PER_REQUEST}`);
|
|
371
|
+
return {
|
|
372
|
+
invitedOnly: page.invitedOnly,
|
|
373
|
+
total: page.total,
|
|
374
|
+
limit: page.limit,
|
|
375
|
+
people: page.viewers.map((row) => ({
|
|
376
|
+
email: row.email,
|
|
377
|
+
role: enumName(row.role),
|
|
378
|
+
owner: row.owner,
|
|
379
|
+
status: enumName(row.status)
|
|
380
|
+
}))
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
async function invitePeople(credentials, siteId, emails, role) {
|
|
384
|
+
return await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers`, {
|
|
385
|
+
method: "POST",
|
|
386
|
+
body: JSON.stringify({ emails, role: role === "comment" ? "Comment" : "View", notify: true })
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async function removePerson(credentials, siteId, email) {
|
|
390
|
+
await call(
|
|
391
|
+
credentials,
|
|
392
|
+
`sites/${encodeURIComponent(siteId)}/viewers?email=${encodeURIComponent(email)}`,
|
|
393
|
+
{ method: "DELETE" }
|
|
394
|
+
);
|
|
395
|
+
}
|
|
357
396
|
const VERSIONS_LISTED = 10;
|
|
358
397
|
function servingModeOf$1(site) {
|
|
359
398
|
if (site.spaMode) return "spa";
|
|
@@ -382,6 +421,7 @@ function describeSite(site, folderPath) {
|
|
|
382
421
|
` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
|
|
383
422
|
` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
|
|
384
423
|
` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
|
|
424
|
+
` comments ${site.comments ?? "unknown"}${unavailable(site.commentsAvailable)}`,
|
|
385
425
|
` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
|
|
386
426
|
` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
|
|
387
427
|
"",
|
|
@@ -613,7 +653,9 @@ async function prepareDeploy(siteId, files, options, signal) {
|
|
|
613
653
|
}));
|
|
614
654
|
const prepared = await request(
|
|
615
655
|
`sites/${encodeURIComponent(siteId)}/deploys/prepare`,
|
|
616
|
-
|
|
656
|
+
// `batches` asks for batch permits; an API that predates them ignores the field, and one that
|
|
657
|
+
// knows them answers with single permits until they are switched on.
|
|
658
|
+
{ files: manifest, batches: true },
|
|
617
659
|
options,
|
|
618
660
|
signal
|
|
619
661
|
);
|
|
@@ -702,6 +744,47 @@ async function problemToError(response) {
|
|
|
702
744
|
const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
|
|
703
745
|
return new DeployError(code, message, problem);
|
|
704
746
|
}
|
|
747
|
+
function batchLayout(members) {
|
|
748
|
+
return members.map((member) => `${member.sha256} ${member.size}
|
|
749
|
+
`).join("");
|
|
750
|
+
}
|
|
751
|
+
function isBatch(target) {
|
|
752
|
+
return "files" in target;
|
|
753
|
+
}
|
|
754
|
+
function parseBatchResult(body) {
|
|
755
|
+
try {
|
|
756
|
+
const parsed = JSON.parse(body);
|
|
757
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
758
|
+
const { stored, present, failed } = parsed;
|
|
759
|
+
if (typeof stored !== "number" || typeof present !== "number" || !Array.isArray(failed))
|
|
760
|
+
return null;
|
|
761
|
+
return { stored, present, failed };
|
|
762
|
+
} catch {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function mapInOrder(items, limit, fn, signal) {
|
|
767
|
+
const results = new Array(items.length);
|
|
768
|
+
let next = 0;
|
|
769
|
+
let failure = null;
|
|
770
|
+
const lane = async () => {
|
|
771
|
+
for (; ; ) {
|
|
772
|
+
if (failure !== null) return;
|
|
773
|
+
const index = next++;
|
|
774
|
+
if (index >= items.length) return;
|
|
775
|
+
try {
|
|
776
|
+
results[index] = await fn(items[index], index);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
failure ??= { error };
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
const lanes = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, lane);
|
|
784
|
+
await Promise.all(lanes);
|
|
785
|
+
if (failure !== null) throw failure.error;
|
|
786
|
+
return results;
|
|
787
|
+
}
|
|
705
788
|
const FILE_TYPES = [
|
|
706
789
|
// Documents. The three extensions that make a folder a documents site.
|
|
707
790
|
{
|
|
@@ -816,13 +899,14 @@ const FILE_TYPES = [
|
|
|
816
899
|
reader: true,
|
|
817
900
|
editable: true
|
|
818
901
|
},
|
|
819
|
-
// Source the viewer highlights.
|
|
902
|
+
// Source the viewer highlights. Inside a documents site a link straight to one opens it in the
|
|
903
|
+
// reader; on an ordinary site, and with `?raw`, it hands over the file.
|
|
820
904
|
{
|
|
821
905
|
ext: ".css",
|
|
822
906
|
mime: "text/css; charset=utf-8",
|
|
823
907
|
kind: "code",
|
|
824
908
|
document: false,
|
|
825
|
-
reader:
|
|
909
|
+
reader: true,
|
|
826
910
|
editable: true
|
|
827
911
|
},
|
|
828
912
|
{
|
|
@@ -830,7 +914,7 @@ const FILE_TYPES = [
|
|
|
830
914
|
mime: "text/javascript; charset=utf-8",
|
|
831
915
|
kind: "code",
|
|
832
916
|
document: false,
|
|
833
|
-
reader:
|
|
917
|
+
reader: true,
|
|
834
918
|
editable: true
|
|
835
919
|
},
|
|
836
920
|
{
|
|
@@ -838,7 +922,7 @@ const FILE_TYPES = [
|
|
|
838
922
|
mime: null,
|
|
839
923
|
kind: "code",
|
|
840
924
|
document: false,
|
|
841
|
-
reader:
|
|
925
|
+
reader: true,
|
|
842
926
|
editable: false
|
|
843
927
|
},
|
|
844
928
|
{
|
|
@@ -846,7 +930,7 @@ const FILE_TYPES = [
|
|
|
846
930
|
mime: null,
|
|
847
931
|
kind: "code",
|
|
848
932
|
document: false,
|
|
849
|
-
reader:
|
|
933
|
+
reader: true,
|
|
850
934
|
editable: false
|
|
851
935
|
},
|
|
852
936
|
{
|
|
@@ -854,7 +938,7 @@ const FILE_TYPES = [
|
|
|
854
938
|
mime: null,
|
|
855
939
|
kind: "code",
|
|
856
940
|
document: false,
|
|
857
|
-
reader:
|
|
941
|
+
reader: true,
|
|
858
942
|
editable: false
|
|
859
943
|
},
|
|
860
944
|
{
|
|
@@ -862,19 +946,18 @@ const FILE_TYPES = [
|
|
|
862
946
|
mime: null,
|
|
863
947
|
kind: "code",
|
|
864
948
|
document: false,
|
|
865
|
-
reader:
|
|
949
|
+
reader: true,
|
|
866
950
|
editable: false
|
|
867
951
|
},
|
|
868
952
|
// The languages an assistant writes most, beyond the web's own. Same row as `.ts`: shown as source
|
|
869
|
-
//
|
|
870
|
-
// link names it directly, and no media type — adding one changes what a browser does with a
|
|
953
|
+
// in the reader, handed over as a file with `?raw` or on an ordinary site, and no media type — adding one changes what a browser does with a
|
|
871
954
|
// published file, which I10 makes a decision of its own rather than a side effect of this list.
|
|
872
955
|
{
|
|
873
956
|
ext: ".py",
|
|
874
957
|
mime: null,
|
|
875
958
|
kind: "code",
|
|
876
959
|
document: false,
|
|
877
|
-
reader:
|
|
960
|
+
reader: true,
|
|
878
961
|
editable: false
|
|
879
962
|
},
|
|
880
963
|
{
|
|
@@ -882,7 +965,7 @@ const FILE_TYPES = [
|
|
|
882
965
|
mime: null,
|
|
883
966
|
kind: "code",
|
|
884
967
|
document: false,
|
|
885
|
-
reader:
|
|
968
|
+
reader: true,
|
|
886
969
|
editable: false
|
|
887
970
|
},
|
|
888
971
|
{
|
|
@@ -890,7 +973,7 @@ const FILE_TYPES = [
|
|
|
890
973
|
mime: null,
|
|
891
974
|
kind: "code",
|
|
892
975
|
document: false,
|
|
893
|
-
reader:
|
|
976
|
+
reader: true,
|
|
894
977
|
editable: false
|
|
895
978
|
},
|
|
896
979
|
{
|
|
@@ -898,7 +981,7 @@ const FILE_TYPES = [
|
|
|
898
981
|
mime: null,
|
|
899
982
|
kind: "code",
|
|
900
983
|
document: false,
|
|
901
|
-
reader:
|
|
984
|
+
reader: true,
|
|
902
985
|
editable: false
|
|
903
986
|
},
|
|
904
987
|
{
|
|
@@ -906,7 +989,7 @@ const FILE_TYPES = [
|
|
|
906
989
|
mime: null,
|
|
907
990
|
kind: "code",
|
|
908
991
|
document: false,
|
|
909
|
-
reader:
|
|
992
|
+
reader: true,
|
|
910
993
|
editable: false
|
|
911
994
|
},
|
|
912
995
|
{
|
|
@@ -914,7 +997,7 @@ const FILE_TYPES = [
|
|
|
914
997
|
mime: null,
|
|
915
998
|
kind: "code",
|
|
916
999
|
document: false,
|
|
917
|
-
reader:
|
|
1000
|
+
reader: true,
|
|
918
1001
|
editable: false
|
|
919
1002
|
},
|
|
920
1003
|
{
|
|
@@ -922,7 +1005,7 @@ const FILE_TYPES = [
|
|
|
922
1005
|
mime: null,
|
|
923
1006
|
kind: "code",
|
|
924
1007
|
document: false,
|
|
925
|
-
reader:
|
|
1008
|
+
reader: true,
|
|
926
1009
|
editable: false
|
|
927
1010
|
},
|
|
928
1011
|
{
|
|
@@ -930,7 +1013,7 @@ const FILE_TYPES = [
|
|
|
930
1013
|
mime: null,
|
|
931
1014
|
kind: "code",
|
|
932
1015
|
document: false,
|
|
933
|
-
reader:
|
|
1016
|
+
reader: true,
|
|
934
1017
|
editable: false
|
|
935
1018
|
},
|
|
936
1019
|
{
|
|
@@ -938,7 +1021,7 @@ const FILE_TYPES = [
|
|
|
938
1021
|
mime: null,
|
|
939
1022
|
kind: "code",
|
|
940
1023
|
document: false,
|
|
941
|
-
reader:
|
|
1024
|
+
reader: true,
|
|
942
1025
|
editable: false
|
|
943
1026
|
},
|
|
944
1027
|
{
|
|
@@ -946,7 +1029,7 @@ const FILE_TYPES = [
|
|
|
946
1029
|
mime: null,
|
|
947
1030
|
kind: "code",
|
|
948
1031
|
document: false,
|
|
949
|
-
reader:
|
|
1032
|
+
reader: true,
|
|
950
1033
|
editable: false
|
|
951
1034
|
},
|
|
952
1035
|
{
|
|
@@ -954,7 +1037,7 @@ const FILE_TYPES = [
|
|
|
954
1037
|
mime: null,
|
|
955
1038
|
kind: "code",
|
|
956
1039
|
document: false,
|
|
957
|
-
reader:
|
|
1040
|
+
reader: true,
|
|
958
1041
|
editable: false
|
|
959
1042
|
},
|
|
960
1043
|
{
|
|
@@ -962,7 +1045,7 @@ const FILE_TYPES = [
|
|
|
962
1045
|
mime: null,
|
|
963
1046
|
kind: "code",
|
|
964
1047
|
document: false,
|
|
965
|
-
reader:
|
|
1048
|
+
reader: true,
|
|
966
1049
|
editable: false
|
|
967
1050
|
},
|
|
968
1051
|
{
|
|
@@ -970,7 +1053,7 @@ const FILE_TYPES = [
|
|
|
970
1053
|
mime: null,
|
|
971
1054
|
kind: "code",
|
|
972
1055
|
document: false,
|
|
973
|
-
reader:
|
|
1056
|
+
reader: true,
|
|
974
1057
|
editable: false
|
|
975
1058
|
},
|
|
976
1059
|
{
|
|
@@ -978,7 +1061,7 @@ const FILE_TYPES = [
|
|
|
978
1061
|
mime: null,
|
|
979
1062
|
kind: "code",
|
|
980
1063
|
document: false,
|
|
981
|
-
reader:
|
|
1064
|
+
reader: true,
|
|
982
1065
|
editable: false
|
|
983
1066
|
},
|
|
984
1067
|
{
|
|
@@ -986,7 +1069,7 @@ const FILE_TYPES = [
|
|
|
986
1069
|
mime: "application/xml",
|
|
987
1070
|
kind: "code",
|
|
988
1071
|
document: false,
|
|
989
|
-
reader:
|
|
1072
|
+
reader: true,
|
|
990
1073
|
editable: true
|
|
991
1074
|
},
|
|
992
1075
|
// Pictures, shown with an img element.
|
|
@@ -1057,25 +1140,25 @@ const FILE_TYPES = [
|
|
|
1057
1140
|
reader: false,
|
|
1058
1141
|
editable: false
|
|
1059
1142
|
},
|
|
1060
|
-
// A customer's own pages.
|
|
1143
|
+
// A customer's own pages. Inside a documents site a navigation opens the reader, which frames the
|
|
1144
|
+
// page; `?raw` and every ordinary site serve the page itself.
|
|
1061
1145
|
{
|
|
1062
1146
|
ext: ".html",
|
|
1063
1147
|
mime: "text/html; charset=utf-8",
|
|
1064
1148
|
kind: "html",
|
|
1065
1149
|
document: false,
|
|
1066
|
-
reader:
|
|
1150
|
+
reader: true,
|
|
1067
1151
|
editable: true
|
|
1068
1152
|
},
|
|
1069
1153
|
{
|
|
1070
|
-
//
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
1073
|
-
// introduced as a refactor and a refactor that changes what a URL answers is not one.
|
|
1154
|
+
// An HTML page in every respect the Worker decides by extension: media type, the revalidated
|
|
1155
|
+
// cache policy invariant I7 requires, and the badge's `isHtml`. Missing from `TYPES` it used to
|
|
1156
|
+
// download, which inside the reader meant a frame that downloaded instead of showing a page.
|
|
1074
1157
|
ext: ".htm",
|
|
1075
|
-
mime:
|
|
1158
|
+
mime: "text/html; charset=utf-8",
|
|
1076
1159
|
kind: "html",
|
|
1077
1160
|
document: false,
|
|
1078
|
-
reader:
|
|
1161
|
+
reader: true,
|
|
1079
1162
|
editable: true
|
|
1080
1163
|
},
|
|
1081
1164
|
// Everything below renders as nothing — the viewer offers a download — but still needs a media
|
|
@@ -1185,13 +1268,46 @@ async function sha256Hex(bytes) {
|
|
|
1185
1268
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
1186
1269
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1187
1270
|
}
|
|
1271
|
+
const HASH_CONCURRENCY = 4;
|
|
1272
|
+
const HASH_BYTES_IN_FLIGHT = 16 * 1024 * 1024;
|
|
1188
1273
|
async function hashAll(files, onProgress, signal) {
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1274
|
+
const budget = byteBudget(HASH_BYTES_IN_FLIGHT);
|
|
1275
|
+
let done = 0;
|
|
1276
|
+
return mapInOrder(
|
|
1277
|
+
files,
|
|
1278
|
+
HASH_CONCURRENCY,
|
|
1279
|
+
async (file) => {
|
|
1280
|
+
const size = sizeOf(file.bytes);
|
|
1281
|
+
await budget.acquire(size);
|
|
1282
|
+
try {
|
|
1283
|
+
signal?.throwIfAborted();
|
|
1284
|
+
const hashed = { ...file, sha256: await sha256Hex(await readForHash(file)) };
|
|
1285
|
+
done += 1;
|
|
1286
|
+
onProgress?.(done, files.length);
|
|
1287
|
+
return hashed;
|
|
1288
|
+
} finally {
|
|
1289
|
+
budget.release(size);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
function byteBudget(limit) {
|
|
1295
|
+
let held = 0;
|
|
1296
|
+
let waiting = [];
|
|
1297
|
+
return {
|
|
1298
|
+
async acquire(size) {
|
|
1299
|
+
while (held > 0 && held + size > limit) {
|
|
1300
|
+
await new Promise((resolve) => waiting.push(resolve));
|
|
1301
|
+
}
|
|
1302
|
+
held += size;
|
|
1303
|
+
},
|
|
1304
|
+
release(size) {
|
|
1305
|
+
held -= size;
|
|
1306
|
+
const woken = waiting;
|
|
1307
|
+
waiting = [];
|
|
1308
|
+
for (const wake of woken) wake();
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1195
1311
|
}
|
|
1196
1312
|
async function readForHash(file) {
|
|
1197
1313
|
try {
|
|
@@ -1220,10 +1336,13 @@ const IGNORED_DIRECTORIES = [
|
|
|
1220
1336
|
".codex",
|
|
1221
1337
|
".gemini"
|
|
1222
1338
|
];
|
|
1339
|
+
function isEnvironmentFile(name) {
|
|
1340
|
+
return name === ".env" || name === ".envrc" || name.startsWith(".env.");
|
|
1341
|
+
}
|
|
1223
1342
|
function shouldIgnore(path) {
|
|
1224
1343
|
const segments = path.split("/");
|
|
1225
1344
|
const name = segments.at(-1) ?? "";
|
|
1226
|
-
if (name === "" || IGNORED_EXACT.has(name)) return true;
|
|
1345
|
+
if (name === "" || IGNORED_EXACT.has(name) || isEnvironmentFile(name)) return true;
|
|
1227
1346
|
if (name.startsWith("._")) return true;
|
|
1228
1347
|
return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
|
|
1229
1348
|
}
|
|
@@ -1358,9 +1477,7 @@ async function readSiteName(files) {
|
|
|
1358
1477
|
}
|
|
1359
1478
|
}
|
|
1360
1479
|
function decodeHead(bytes) {
|
|
1361
|
-
return new TextDecoder("utf-8", { fatal: false }).decode(
|
|
1362
|
-
bytes.subarray(0, HEAD_BYTES)
|
|
1363
|
-
);
|
|
1480
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, HEAD_BYTES));
|
|
1364
1481
|
}
|
|
1365
1482
|
function nameFromHtml(html) {
|
|
1366
1483
|
const document = parse(html);
|
|
@@ -1385,9 +1502,7 @@ function parse(html) {
|
|
|
1385
1502
|
}
|
|
1386
1503
|
}
|
|
1387
1504
|
function matchTag(html, tag) {
|
|
1388
|
-
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
|
|
1389
|
-
html
|
|
1390
|
-
);
|
|
1505
|
+
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(html);
|
|
1391
1506
|
return match?.[1] === void 0 ? null : decodeEntities(match[1]);
|
|
1392
1507
|
}
|
|
1393
1508
|
function matchOgSiteName(html) {
|
|
@@ -1488,6 +1603,7 @@ function trimSuffix(title) {
|
|
|
1488
1603
|
return title;
|
|
1489
1604
|
}
|
|
1490
1605
|
const DEFAULT_CONCURRENCY = 8;
|
|
1606
|
+
const MAX_BATCH_CONCURRENCY = 64;
|
|
1491
1607
|
const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
|
|
1492
1608
|
const DEFAULT_RETRIES = 3;
|
|
1493
1609
|
const BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
@@ -1504,14 +1620,31 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
|
|
|
1504
1620
|
const put = deps.put ?? defaultTransport(deps.fetch);
|
|
1505
1621
|
const delay = deps.delay ?? defaultDelay;
|
|
1506
1622
|
const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
|
|
1623
|
+
const batchConcurrency = Math.max(
|
|
1624
|
+
1,
|
|
1625
|
+
Math.min(MAX_BATCH_CONCURRENCY, Math.floor(deps.batchConcurrency ?? concurrency))
|
|
1626
|
+
);
|
|
1507
1627
|
const retries = deps.retries ?? DEFAULT_RETRIES;
|
|
1508
1628
|
const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
|
|
1509
1629
|
const now = deps.now ?? Date.now;
|
|
1510
1630
|
const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
|
|
1511
1631
|
const byPath = new Map(files.map((file) => [file.path, file]));
|
|
1632
|
+
const fileCount = targets.reduce(
|
|
1633
|
+
(sum, target) => sum + (isBatch(target) ? target.files.length : 1),
|
|
1634
|
+
0
|
|
1635
|
+
);
|
|
1512
1636
|
let nextIndex = 0;
|
|
1513
1637
|
let done = 0;
|
|
1514
1638
|
let failed = false;
|
|
1639
|
+
let firstFailure;
|
|
1640
|
+
const local = new AbortController();
|
|
1641
|
+
const inner = local.signal;
|
|
1642
|
+
const fail = (error) => {
|
|
1643
|
+
if (failed) return;
|
|
1644
|
+
failed = true;
|
|
1645
|
+
firstFailure = error;
|
|
1646
|
+
local.abort();
|
|
1647
|
+
};
|
|
1515
1648
|
let reserved = 0;
|
|
1516
1649
|
let waiting = [];
|
|
1517
1650
|
const acquire = async (size) => {
|
|
@@ -1526,17 +1659,32 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
|
|
|
1526
1659
|
waiting = [];
|
|
1527
1660
|
for (const wake of woken) wake();
|
|
1528
1661
|
};
|
|
1662
|
+
const running = { single: 0, batch: 0 };
|
|
1663
|
+
const limit = { single: concurrency, batch: batchConcurrency };
|
|
1664
|
+
let slotWaiting = [];
|
|
1665
|
+
const takeSlot = async (kind) => {
|
|
1666
|
+
while (running[kind] >= limit[kind]) {
|
|
1667
|
+
await new Promise((resolve) => slotWaiting.push(resolve));
|
|
1668
|
+
}
|
|
1669
|
+
running[kind] += 1;
|
|
1670
|
+
};
|
|
1671
|
+
const freeSlot = (kind) => {
|
|
1672
|
+
running[kind] -= 1;
|
|
1673
|
+
const woken = slotWaiting;
|
|
1674
|
+
slotWaiting = [];
|
|
1675
|
+
for (const wake of woken) wake();
|
|
1676
|
+
};
|
|
1529
1677
|
let landedBytes = 0;
|
|
1530
1678
|
const inFlight = /* @__PURE__ */ new Map();
|
|
1531
1679
|
let lastReport = Number.NEGATIVE_INFINITY;
|
|
1532
1680
|
const report2 = (path) => {
|
|
1533
|
-
if (onProgress === void 0) return;
|
|
1681
|
+
if (onProgress === void 0 || failed) return;
|
|
1534
1682
|
const at = now();
|
|
1535
1683
|
if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
|
|
1536
1684
|
lastReport = at;
|
|
1537
1685
|
let bytes = landedBytes;
|
|
1538
1686
|
for (const sent of inFlight.values()) bytes += sent;
|
|
1539
|
-
onProgress(done,
|
|
1687
|
+
onProgress(done, fileCount, bytes, path);
|
|
1540
1688
|
};
|
|
1541
1689
|
const worker = async () => {
|
|
1542
1690
|
for (; ; ) {
|
|
@@ -1544,25 +1692,49 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
|
|
|
1544
1692
|
const index = nextIndex++;
|
|
1545
1693
|
const target = targets[index];
|
|
1546
1694
|
if (target === void 0) return;
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1695
|
+
inner.throwIfAborted();
|
|
1696
|
+
const refs = isBatch(target) ? target.files : [target];
|
|
1697
|
+
const members = [];
|
|
1698
|
+
for (const ref of refs) {
|
|
1699
|
+
const file = byPath.get(ref.path);
|
|
1700
|
+
if (file === void 0) {
|
|
1701
|
+
const missing = new DeployError(
|
|
1702
|
+
ClientErrorCode.UploadFailed,
|
|
1703
|
+
`The server asked for ${ref.path}, which is not in the manifest.`,
|
|
1704
|
+
{ path: ref.path }
|
|
1705
|
+
);
|
|
1706
|
+
fail(missing);
|
|
1707
|
+
throw missing;
|
|
1708
|
+
}
|
|
1709
|
+
members.push(file);
|
|
1555
1710
|
}
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1711
|
+
const layout = isBatch(target) ? new TextEncoder().encode(
|
|
1712
|
+
batchLayout(
|
|
1713
|
+
target.files.map((ref, i) => ({
|
|
1714
|
+
sha256: ref.sha256,
|
|
1715
|
+
size: sizeOf(members[i].bytes)
|
|
1716
|
+
}))
|
|
1717
|
+
)
|
|
1718
|
+
) : null;
|
|
1719
|
+
const size = members.reduce((sum, file) => sum + sizeOf(file.bytes), layout?.length ?? 0);
|
|
1720
|
+
const copied = isBatch(target) ? members.reduce(
|
|
1721
|
+
(sum, file) => sum + (file.bytes instanceof Blob ? 0 : sizeOf(file.bytes)),
|
|
1722
|
+
0
|
|
1723
|
+
) : 0;
|
|
1724
|
+
const reservation = size + copied;
|
|
1725
|
+
const first = members[0].path;
|
|
1726
|
+
const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
|
|
1727
|
+
const kind = isBatch(target) ? "batch" : "single";
|
|
1728
|
+
await takeSlot(kind);
|
|
1729
|
+
await acquire(reservation);
|
|
1558
1730
|
try {
|
|
1559
1731
|
if (failed) return;
|
|
1560
|
-
|
|
1561
|
-
|
|
1732
|
+
inner.throwIfAborted();
|
|
1733
|
+
const context = {
|
|
1562
1734
|
put,
|
|
1563
1735
|
delay,
|
|
1564
1736
|
retries,
|
|
1565
|
-
signal,
|
|
1737
|
+
signal: inner,
|
|
1566
1738
|
uploadUrl,
|
|
1567
1739
|
stallMs,
|
|
1568
1740
|
onSent: (sent) => {
|
|
@@ -1571,26 +1743,34 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
|
|
|
1571
1743
|
},
|
|
1572
1744
|
onRetry: (attempt2, stalled) => {
|
|
1573
1745
|
inFlight.set(index, 0);
|
|
1574
|
-
onRetry?.(
|
|
1746
|
+
onRetry?.(label, attempt2, retries + 1, stalled);
|
|
1575
1747
|
}
|
|
1576
|
-
}
|
|
1748
|
+
};
|
|
1749
|
+
if (isBatch(target) && layout !== null)
|
|
1750
|
+
await uploadBatchOne(target, members, layout, context);
|
|
1751
|
+
else await uploadOne(target, members[0], context);
|
|
1577
1752
|
} catch (error) {
|
|
1578
|
-
|
|
1753
|
+
fail(error);
|
|
1579
1754
|
throw error;
|
|
1580
1755
|
} finally {
|
|
1581
|
-
release(
|
|
1756
|
+
release(reservation);
|
|
1757
|
+
freeSlot(kind);
|
|
1582
1758
|
}
|
|
1583
1759
|
inFlight.delete(index);
|
|
1584
|
-
done +=
|
|
1760
|
+
done += members.length;
|
|
1585
1761
|
landedBytes += size;
|
|
1586
|
-
report2(
|
|
1762
|
+
report2(members.at(-1).path);
|
|
1587
1763
|
}
|
|
1588
1764
|
};
|
|
1589
|
-
const workers = Array.from(
|
|
1765
|
+
const workers = Array.from(
|
|
1766
|
+
{ length: Math.min(Math.max(concurrency, batchConcurrency), targets.length) },
|
|
1767
|
+
worker
|
|
1768
|
+
);
|
|
1590
1769
|
try {
|
|
1591
1770
|
await Promise.all(workers);
|
|
1592
1771
|
} catch (error) {
|
|
1593
|
-
throw asDeployError(error);
|
|
1772
|
+
throw asDeployError(firstFailure ?? error);
|
|
1773
|
+
} finally {
|
|
1594
1774
|
}
|
|
1595
1775
|
}
|
|
1596
1776
|
async function uploadOne(target, file, context) {
|
|
@@ -1603,7 +1783,7 @@ async function uploadOne(target, file, context) {
|
|
|
1603
1783
|
context.signal?.throwIfAborted();
|
|
1604
1784
|
}
|
|
1605
1785
|
try {
|
|
1606
|
-
const
|
|
1786
|
+
const answer = await context.put({
|
|
1607
1787
|
url: context.uploadUrl,
|
|
1608
1788
|
token: target.token,
|
|
1609
1789
|
body: untyped(file.bytes),
|
|
@@ -1611,6 +1791,7 @@ async function uploadOne(target, file, context) {
|
|
|
1611
1791
|
onSent: context.onSent,
|
|
1612
1792
|
stallMs: context.stallMs
|
|
1613
1793
|
});
|
|
1794
|
+
const status = typeof answer === "number" ? answer : answer.status;
|
|
1614
1795
|
if (status >= 200 && status < 300) return;
|
|
1615
1796
|
if (status === 401 || status === 403 || status === 429) {
|
|
1616
1797
|
throw new DeployError(
|
|
@@ -1639,6 +1820,85 @@ async function uploadOne(target, file, context) {
|
|
|
1639
1820
|
cause: describeError(lastError)
|
|
1640
1821
|
});
|
|
1641
1822
|
}
|
|
1823
|
+
async function uploadBatchOne(batch, members, layout, context) {
|
|
1824
|
+
const first = members[0].path;
|
|
1825
|
+
const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
|
|
1826
|
+
let lastError;
|
|
1827
|
+
for (let attempt2 = 0; attempt2 <= context.retries; attempt2++) {
|
|
1828
|
+
context.signal?.throwIfAborted();
|
|
1829
|
+
if (attempt2 > 0) {
|
|
1830
|
+
context.onRetry(attempt2 + 1, lastError instanceof StalledError);
|
|
1831
|
+
await context.delay(BACKOFF_MS[attempt2 - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
|
|
1832
|
+
context.signal?.throwIfAborted();
|
|
1833
|
+
}
|
|
1834
|
+
try {
|
|
1835
|
+
const body = new Blob([layout, ...members.map((file) => file.bytes)]);
|
|
1836
|
+
const answer = await context.put({
|
|
1837
|
+
url: context.uploadUrl,
|
|
1838
|
+
token: batch.token,
|
|
1839
|
+
body,
|
|
1840
|
+
signal: context.signal,
|
|
1841
|
+
onSent: context.onSent,
|
|
1842
|
+
stallMs: context.stallMs,
|
|
1843
|
+
wantsBody: true
|
|
1844
|
+
});
|
|
1845
|
+
const status = typeof answer === "number" ? answer : answer.status;
|
|
1846
|
+
if (status >= 200 && status < 300) {
|
|
1847
|
+
const result = typeof answer === "number" ? null : parseBatchResult(answer.body);
|
|
1848
|
+
if (result === null) {
|
|
1849
|
+
lastError = new Error(
|
|
1850
|
+
"The upload service answered with something that is not a batch result."
|
|
1851
|
+
);
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
if (result.failed.length === 0) return;
|
|
1855
|
+
const changed = result.failed.find((failure) => failure.reason === "hash_mismatch");
|
|
1856
|
+
if (changed !== void 0) {
|
|
1857
|
+
const path = members[changed.i]?.path ?? first;
|
|
1858
|
+
throw new DeployError(
|
|
1859
|
+
ClientErrorCode.ReadFailed,
|
|
1860
|
+
`${path} changed after it was read, so it no longer matches what was checked. Drop the folder again.`,
|
|
1861
|
+
{ path }
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
lastError = new Error(
|
|
1865
|
+
`${result.failed.length} of ${members.length} files were not stored.`
|
|
1866
|
+
);
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
if ([400, 401, 403, 411, 413, 429].includes(status)) {
|
|
1870
|
+
throw new DeployError(ClientErrorCode.UploadFailed, refusalMessage(status, label), {
|
|
1871
|
+
path: first,
|
|
1872
|
+
status
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
lastError = new Error(`HTTP ${status}`);
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
if (error instanceof DeployError) throw error;
|
|
1878
|
+
if (isAbort(error)) throw cancelled();
|
|
1879
|
+
lastError = error;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
const message = lastError instanceof StalledError ? `Upload of ${label} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${label} after ${context.retries + 1} attempts.`;
|
|
1883
|
+
throw new DeployError(ClientErrorCode.UploadFailed, message, {
|
|
1884
|
+
path: first,
|
|
1885
|
+
cause: describeError(lastError)
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
function refusalMessage(status, label) {
|
|
1889
|
+
switch (status) {
|
|
1890
|
+
case 401:
|
|
1891
|
+
return `Upload of ${label} was refused because its upload window expired. Try deploying again.`;
|
|
1892
|
+
case 429:
|
|
1893
|
+
return `Upload of ${label} was refused: this deploy has used every upload it was given. Try deploying again.`;
|
|
1894
|
+
case 400:
|
|
1895
|
+
case 411:
|
|
1896
|
+
case 413:
|
|
1897
|
+
return `Upload of ${label} was refused because it no longer matches what was checked — a file may have changed since you dropped it. Drop the folder again.`;
|
|
1898
|
+
default:
|
|
1899
|
+
return `Upload of ${label} was refused by the upload service. Try deploying again.`;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1642
1902
|
function untyped(content) {
|
|
1643
1903
|
if (ArrayBuffer.isView(content) || content.type === "") return content;
|
|
1644
1904
|
return content.slice(0, content.size);
|
|
@@ -1649,7 +1909,8 @@ function defaultTransport(doFetch) {
|
|
|
1649
1909
|
return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
|
|
1650
1910
|
}
|
|
1651
1911
|
function fetchTransport(doFetch) {
|
|
1652
|
-
return async (
|
|
1912
|
+
return async (request2) => {
|
|
1913
|
+
const { url, token, body, signal } = request2;
|
|
1653
1914
|
const response = await doFetch(url, {
|
|
1654
1915
|
method: "PUT",
|
|
1655
1916
|
// The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
|
|
@@ -1664,11 +1925,11 @@ function fetchTransport(doFetch) {
|
|
|
1664
1925
|
headers: { Authorization: `Bearer ${token}` },
|
|
1665
1926
|
...signal ? { signal } : {}
|
|
1666
1927
|
});
|
|
1667
|
-
return response.status;
|
|
1928
|
+
return request2.wantsBody === true ? { status: response.status, body: await response.text() } : response.status;
|
|
1668
1929
|
};
|
|
1669
1930
|
}
|
|
1670
1931
|
function xhrTransport(Xhr) {
|
|
1671
|
-
return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve, reject) => {
|
|
1932
|
+
return ({ url, token, body, signal, onSent, stallMs, wantsBody }) => new Promise((resolve, reject) => {
|
|
1672
1933
|
const xhr = new Xhr();
|
|
1673
1934
|
let settled = false;
|
|
1674
1935
|
let timer;
|
|
@@ -1702,7 +1963,11 @@ function xhrTransport(Xhr) {
|
|
|
1702
1963
|
onSent(event.loaded);
|
|
1703
1964
|
};
|
|
1704
1965
|
xhr.onprogress = arm;
|
|
1705
|
-
xhr.onload = () => settle(
|
|
1966
|
+
xhr.onload = () => settle(
|
|
1967
|
+
() => resolve(
|
|
1968
|
+
wantsBody === true ? { status: xhr.status, body: xhr.responseText ?? "" } : xhr.status
|
|
1969
|
+
)
|
|
1970
|
+
);
|
|
1706
1971
|
xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
|
|
1707
1972
|
arm();
|
|
1708
1973
|
xhr.send(body);
|
|
@@ -1767,11 +2032,16 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1767
2032
|
return;
|
|
1768
2033
|
}
|
|
1769
2034
|
const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
|
|
1770
|
-
const
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
2035
|
+
const batches = prepared.batches ?? [];
|
|
2036
|
+
const targets = [...prepared.upload, ...batches];
|
|
2037
|
+
const totalBytes = prepared.upload.reduce((sum, target) => sum + (sizes.get(target.path) ?? 0), 0) + batches.reduce((sum, batch) => {
|
|
2038
|
+
const members = batch.files.map((ref) => ({
|
|
2039
|
+
sha256: ref.sha256,
|
|
2040
|
+
size: sizes.get(ref.path) ?? 0
|
|
2041
|
+
}));
|
|
2042
|
+
return sum + batchLayout(members).length + members.reduce((n, member) => n + member.size, 0);
|
|
2043
|
+
}, 0);
|
|
2044
|
+
if (targets.length > 0) {
|
|
1775
2045
|
onProgress({
|
|
1776
2046
|
type: "uploading",
|
|
1777
2047
|
done: prepared.reused,
|
|
@@ -1782,7 +2052,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1782
2052
|
});
|
|
1783
2053
|
}
|
|
1784
2054
|
await uploadAll(
|
|
1785
|
-
|
|
2055
|
+
targets,
|
|
1786
2056
|
prepared.uploadUrl,
|
|
1787
2057
|
hashed,
|
|
1788
2058
|
(done, _total, bytes, path) => onProgress({
|
|
@@ -1795,7 +2065,9 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1795
2065
|
...path === void 0 ? {} : { path }
|
|
1796
2066
|
}),
|
|
1797
2067
|
signal,
|
|
1798
|
-
|
|
2068
|
+
// The server's number unless the caller set one: it is tuned on the server so a deploy of many
|
|
2069
|
+
// small files speeds up without a CLI or MCP release.
|
|
2070
|
+
typeof prepared.batchConcurrency === "number" && options.upload?.batchConcurrency === void 0 ? { ...options.upload, batchConcurrency: prepared.batchConcurrency } : options.upload,
|
|
1799
2071
|
(path, attempt2, attempts, stalled) => onProgress({ type: "retrying", path, attempt: attempt2, attempts, stalled })
|
|
1800
2072
|
);
|
|
1801
2073
|
onProgress({ type: "completing", reused: prepared.reused });
|
|
@@ -2154,7 +2426,7 @@ async function signInWithCode(waitSeconds, replace = false) {
|
|
|
2154
2426
|
"Call this tool again to keep waiting. Nothing has been changed yet."
|
|
2155
2427
|
].join("\n");
|
|
2156
2428
|
}
|
|
2157
|
-
const SERVER_VERSION = "0.
|
|
2429
|
+
const SERVER_VERSION = "0.12.0";
|
|
2158
2430
|
const CREDENTIAL_WRITE = {
|
|
2159
2431
|
readOnlyHint: false,
|
|
2160
2432
|
destructiveHint: false,
|
|
@@ -2204,6 +2476,12 @@ const ANSWERS_A_COMMENT = {
|
|
|
2204
2476
|
idempotentHint: false,
|
|
2205
2477
|
openWorldHint: true
|
|
2206
2478
|
};
|
|
2479
|
+
const INVITES_PEOPLE = {
|
|
2480
|
+
readOnlyHint: false,
|
|
2481
|
+
destructiveHint: false,
|
|
2482
|
+
idempotentHint: true,
|
|
2483
|
+
openWorldHint: true
|
|
2484
|
+
};
|
|
2207
2485
|
const MARKS_A_THREAD = {
|
|
2208
2486
|
readOnlyHint: false,
|
|
2209
2487
|
destructiveHint: false,
|
|
@@ -2292,6 +2570,9 @@ const SETTINGS_OUTPUT = {
|
|
|
2292
2570
|
"Whether only invited people can open it, or null when that could not be read. Null does not mean public."
|
|
2293
2571
|
),
|
|
2294
2572
|
formsEnabled: z.boolean().describe("Whether it accepts form submissions."),
|
|
2573
|
+
comments: z.enum(["off", "invited", "anyone"]).nullable().describe(
|
|
2574
|
+
"Who may comment on its pages: nobody, the people invited to comment (only while it is invite-only), or anybody signed in. Null when that could not be read."
|
|
2575
|
+
),
|
|
2295
2576
|
expiresAt: z.string().nullable().describe("When it is scheduled to come down, or null."),
|
|
2296
2577
|
expiryAction: z.string().nullable().describe("What the takedown does: `pause` or `delete`, or null when none is scheduled."),
|
|
2297
2578
|
folderId: z.string().nullable().describe("The folder it is filed in, or null at the top level.")
|
|
@@ -2320,10 +2601,12 @@ function structuredSite(site) {
|
|
|
2320
2601
|
expiresAt: site.expiresAt,
|
|
2321
2602
|
expiryAction: site.expiryAction,
|
|
2322
2603
|
folderId: site.folderId,
|
|
2604
|
+
comments: site.comments,
|
|
2323
2605
|
available: {
|
|
2324
2606
|
password: site.passwordProtectionAvailable,
|
|
2325
2607
|
forms: site.formsAvailable,
|
|
2326
|
-
scheduledTakedown: site.scheduledExpiryAvailable
|
|
2608
|
+
scheduledTakedown: site.scheduledExpiryAvailable,
|
|
2609
|
+
comments: site.commentsAvailable
|
|
2327
2610
|
},
|
|
2328
2611
|
versions: site.deploys.map((deploy2) => ({
|
|
2329
2612
|
deployId: deploy2.deployId,
|
|
@@ -2426,6 +2709,12 @@ function createServer() {
|
|
|
2426
2709
|
"a password or a date only when the person gave one, and scheduling a deletion asks for",
|
|
2427
2710
|
"`confirm` the way delete_site does.",
|
|
2428
2711
|
"",
|
|
2712
|
+
"To share a site for review: invite_people with the addresses the person gave (each is",
|
|
2713
|
+
"emailed), update_site `invitedOnly: true` so only they can open it, and `comments` to let",
|
|
2714
|
+
"them comment — `invited` for the people invited with the comment role, `anyone` for",
|
|
2715
|
+
"anybody signed in. list_people shows the list; remove_person takes someone off it.",
|
|
2716
|
+
"Never invite an address the person did not give you.",
|
|
2717
|
+
"",
|
|
2429
2718
|
"People viewing a site can leave comments pinned to its pages. list_comments reads them,",
|
|
2430
2719
|
"with the page and the words each one points at, so the source can be found and changed.",
|
|
2431
2720
|
'After changing it, publish first and reply second — a reply that says "fixed" before the',
|
|
@@ -2597,7 +2886,8 @@ function createServer() {
|
|
|
2597
2886
|
available: z.object({
|
|
2598
2887
|
password: z.boolean(),
|
|
2599
2888
|
forms: z.boolean(),
|
|
2600
|
-
scheduledTakedown: z.boolean()
|
|
2889
|
+
scheduledTakedown: z.boolean(),
|
|
2890
|
+
comments: z.boolean()
|
|
2601
2891
|
}).describe("Which settings the account's plan includes."),
|
|
2602
2892
|
versions: z.array(VERSION_OUTPUT).describe("Its versions, newest first.")
|
|
2603
2893
|
},
|
|
@@ -2636,6 +2926,12 @@ function createServer() {
|
|
|
2636
2926
|
folder: z.string().optional().describe(
|
|
2637
2927
|
"Folder to move the site to — a path such as Clients/Acme or an id from list_folders — or `root` for the top level. Never creates a folder."
|
|
2638
2928
|
),
|
|
2929
|
+
invitedOnly: z.boolean().optional().describe(
|
|
2930
|
+
"True: only the people on the site's list (invite_people) can open it, each signed in. It replaces a password. False: anybody with the link can open it again. Change it only when the person asked who should see the site."
|
|
2931
|
+
),
|
|
2932
|
+
comments: z.enum(["off", "invited", "anyone"]).optional().describe(
|
|
2933
|
+
"Who may comment on the site's pages: `off`; `invited` — the people invited with the comment role, on an invite-only site; or `anyone` signed in. Only on plans that include comments."
|
|
2934
|
+
),
|
|
2639
2935
|
confirm: z.string().optional().describe(
|
|
2640
2936
|
"Required only with `expiryAction: delete`: the site's subdomain, repeated exactly as the person said it. Ask for it rather than filling it in."
|
|
2641
2937
|
)
|
|
@@ -2643,7 +2939,19 @@ function createServer() {
|
|
|
2643
2939
|
outputSchema: SETTINGS_OUTPUT,
|
|
2644
2940
|
annotations: CHANGES_A_SITE
|
|
2645
2941
|
},
|
|
2646
|
-
({
|
|
2942
|
+
({
|
|
2943
|
+
site,
|
|
2944
|
+
name,
|
|
2945
|
+
mode,
|
|
2946
|
+
password,
|
|
2947
|
+
forms,
|
|
2948
|
+
expiresAt,
|
|
2949
|
+
expiryAction,
|
|
2950
|
+
folder,
|
|
2951
|
+
invitedOnly,
|
|
2952
|
+
comments,
|
|
2953
|
+
confirm
|
|
2954
|
+
}) => withCredentials(async (credentials) => {
|
|
2647
2955
|
const found = await findSite(credentials, site);
|
|
2648
2956
|
if (expiryAction !== void 0 && expiresAt === void 0) {
|
|
2649
2957
|
throw new Error("`expiryAction` only applies with `expiresAt` — send the date too.");
|
|
@@ -2662,7 +2970,9 @@ function createServer() {
|
|
|
2662
2970
|
// `delete` alongside an empty date would still run its owner-only check for nothing.
|
|
2663
2971
|
expiryAction: expiresAt === "" ? void 0 : expiryAction,
|
|
2664
2972
|
formsEnabled: forms,
|
|
2665
|
-
folderId: folder === void 0 ? void 0 : await resolveFolderTarget(credentials, folder)
|
|
2973
|
+
folderId: folder === void 0 ? void 0 : await resolveFolderTarget(credentials, folder),
|
|
2974
|
+
invitedOnly,
|
|
2975
|
+
comments
|
|
2666
2976
|
};
|
|
2667
2977
|
const settings = await updateSiteSettings(credentials, found.siteId, change);
|
|
2668
2978
|
const lines = [
|
|
@@ -2670,9 +2980,15 @@ function createServer() {
|
|
|
2670
2980
|
` access ${accessOf(settings)}`,
|
|
2671
2981
|
` mode ${servingModeOf$1(settings)}`,
|
|
2672
2982
|
` forms ${settings.formsEnabled ? "on" : "off"}`,
|
|
2983
|
+
` comments ${settings.comments ?? "unknown"}`,
|
|
2984
|
+
// The one combination that reads as on and does nothing: invited commenters on a site anybody
|
|
2985
|
+
// can open, which the API keeps (it is the owner's choice for when the site goes private).
|
|
2986
|
+
...settings.comments === "invited" && settings.invitedOnly === false ? [
|
|
2987
|
+
"Comments are set to invited people, but the site is not invite-only, so nobody can comment yet. Set `invitedOnly: true`, or `comments: anyone`."
|
|
2988
|
+
] : [],
|
|
2673
2989
|
` takedown ${settings.expiresAt === null ? "none" : `${settings.expiresAt} (${settings.expiryAction})`}`,
|
|
2674
2990
|
...settings.replacedInviteOnly ? [
|
|
2675
|
-
"The password replaced invite-only: the people on its list can no longer get in without it. The list is kept
|
|
2991
|
+
"The password replaced invite-only: the people on its list can no longer get in without it. The list is kept; `invitedOnly: true` switches back."
|
|
2676
2992
|
] : [],
|
|
2677
2993
|
...settings.live ? ["Reaching every edge takes up to about a minute."] : ["Nothing is published yet, so this applies from the first publish."]
|
|
2678
2994
|
];
|
|
@@ -2683,6 +2999,7 @@ function createServer() {
|
|
|
2683
2999
|
passwordProtected: settings.passwordProtected,
|
|
2684
3000
|
invitedOnly: settings.invitedOnly,
|
|
2685
3001
|
formsEnabled: settings.formsEnabled,
|
|
3002
|
+
comments: settings.comments,
|
|
2686
3003
|
expiresAt: settings.expiresAt,
|
|
2687
3004
|
expiryAction: settings.expiryAction,
|
|
2688
3005
|
folderId: settings.folderId
|
|
@@ -2748,6 +3065,94 @@ ${promoted.url}`,
|
|
|
2748
3065
|
);
|
|
2749
3066
|
})
|
|
2750
3067
|
);
|
|
3068
|
+
server.registerTool(
|
|
3069
|
+
"list_people",
|
|
3070
|
+
{
|
|
3071
|
+
title: "List who a site is shared with",
|
|
3072
|
+
description: "Lists the people on a site's list — the ones invite_people added — with what each may do (`view` or `comment`) and whether they have opened it yet. Says whether the list decides who can open the site, which it does only while the site is invite-only.",
|
|
3073
|
+
inputSchema: { site: SITE_INPUT },
|
|
3074
|
+
outputSchema: {
|
|
3075
|
+
invitedOnly: z.boolean().describe("Whether only the people on the list can open the site now."),
|
|
3076
|
+
total: z.number().int().describe("How many are on the list."),
|
|
3077
|
+
limit: z.number().int().describe("The most the plan allows on one site's list."),
|
|
3078
|
+
people: z.array(
|
|
3079
|
+
z.object({
|
|
3080
|
+
email: z.string(),
|
|
3081
|
+
role: z.string().describe("`view` or `comment`."),
|
|
3082
|
+
owner: z.boolean().describe("Whether this is the site's owner."),
|
|
3083
|
+
status: z.string().describe("`opened` or `not-opened`.")
|
|
3084
|
+
})
|
|
3085
|
+
)
|
|
3086
|
+
},
|
|
3087
|
+
annotations: READS_ONLY
|
|
3088
|
+
},
|
|
3089
|
+
({ site }) => withCredentials(async (credentials) => {
|
|
3090
|
+
const found = await findSite(credentials, site);
|
|
3091
|
+
const list = await listPeople(credentials, found.siteId);
|
|
3092
|
+
const rows = list.people.map(
|
|
3093
|
+
(person) => ` ${person.email} ${person.role}${person.owner ? ", owner" : ""}, ${person.status}`
|
|
3094
|
+
);
|
|
3095
|
+
return report(
|
|
3096
|
+
[
|
|
3097
|
+
`${list.total} of ${list.limit} on ${found.subdomain}'s list. ` + (list.invitedOnly ? "Only they can open it." : "It is not invite-only, so anybody with the link can open it."),
|
|
3098
|
+
...rows,
|
|
3099
|
+
...list.total > list.people.length ? [` … and ${list.total - list.people.length} more, in the dashboard`] : []
|
|
3100
|
+
].join("\n"),
|
|
3101
|
+
{ ...list }
|
|
3102
|
+
);
|
|
3103
|
+
})
|
|
3104
|
+
);
|
|
3105
|
+
server.registerTool(
|
|
3106
|
+
"invite_people",
|
|
3107
|
+
{
|
|
3108
|
+
title: "Invite people to a site",
|
|
3109
|
+
description: "Adds email addresses to a site's list and emails each new one an invitation from the account. Use only addresses the person gave you — never guess, infer or look one up. `comment` lets them comment as well as open it. Inviting does not make the site private; update_site's `invitedOnly: true` does. On plans with private sharing; all or nothing — one bad address, or a list past the plan's limit, adds nobody.",
|
|
3110
|
+
inputSchema: {
|
|
3111
|
+
site: SITE_INPUT,
|
|
3112
|
+
emails: z.array(z.string().min(3)).min(1).max(50).describe("The addresses, exactly as the person gave them."),
|
|
3113
|
+
role: z.enum(["view", "comment"]).optional().describe("`view` (the default) to open the site, `comment` to open it and comment.")
|
|
3114
|
+
},
|
|
3115
|
+
outputSchema: {
|
|
3116
|
+
added: z.array(z.string()).describe("Addresses added and emailed."),
|
|
3117
|
+
alreadyListed: z.array(z.string()).describe("Addresses already on the list, left as they were and not emailed again."),
|
|
3118
|
+
total: z.number().int().describe("How many are on the list now.")
|
|
3119
|
+
},
|
|
3120
|
+
annotations: INVITES_PEOPLE
|
|
3121
|
+
},
|
|
3122
|
+
({ site, emails, role }) => withCredentials(async (credentials) => {
|
|
3123
|
+
const found = await findSite(credentials, site);
|
|
3124
|
+
const invited = await invitePeople(credentials, found.siteId, emails, role ?? "view");
|
|
3125
|
+
const detail = await getSite(credentials, found.siteId);
|
|
3126
|
+
return report(
|
|
3127
|
+
[
|
|
3128
|
+
invited.added.length === 0 ? `Nobody new on ${found.subdomain}'s list.` : `Invited ${invited.added.join(", ")} to ${found.subdomain} — each was emailed.`,
|
|
3129
|
+
...invited.alreadyListed.length === 0 ? [] : [`Already on the list, not emailed again: ${invited.alreadyListed.join(", ")}.`],
|
|
3130
|
+
...detail.invitedOnly === false ? [
|
|
3131
|
+
"The site is not invite-only yet, so the list does not decide who opens it. update_site with `invitedOnly: true` does."
|
|
3132
|
+
] : []
|
|
3133
|
+
].join("\n"),
|
|
3134
|
+
{ ...invited }
|
|
3135
|
+
);
|
|
3136
|
+
})
|
|
3137
|
+
);
|
|
3138
|
+
server.registerTool(
|
|
3139
|
+
"remove_person",
|
|
3140
|
+
{
|
|
3141
|
+
title: "Remove someone from a site's list",
|
|
3142
|
+
description: "Takes one address off a site's list. On an invite-only site their access ends within a minute; putting them back is another invitation, with another email.",
|
|
3143
|
+
inputSchema: {
|
|
3144
|
+
site: SITE_INPUT,
|
|
3145
|
+
email: z.string().min(3).describe("The address to remove, from list_people.")
|
|
3146
|
+
},
|
|
3147
|
+
outputSchema: { email: z.string().describe("The address removed.") },
|
|
3148
|
+
annotations: CHANGES_A_SITE
|
|
3149
|
+
},
|
|
3150
|
+
({ site, email }) => withCredentials(async (credentials) => {
|
|
3151
|
+
const found = await findSite(credentials, site);
|
|
3152
|
+
await removePerson(credentials, found.siteId, email);
|
|
3153
|
+
return report(`Removed ${email} from ${found.subdomain}'s list.`, { email });
|
|
3154
|
+
})
|
|
3155
|
+
);
|
|
2751
3156
|
server.registerTool(
|
|
2752
3157
|
"list_comments",
|
|
2753
3158
|
{
|
package/package.json
CHANGED