drop2run 0.4.1 → 0.6.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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # drop2run
2
2
 
3
- Publish a static site to [Drop2Run](https://dropto.run) from the command line.
3
+ Publish a page, a folder of documents or a built site to [Drop2Run](https://dropto.run) from the
4
+ command line — by hand, from CI, or from a coding agent.
4
5
 
5
6
  ```bash
6
7
  npm i -g drop2run
@@ -17,7 +18,9 @@ drop2run logout Remove the stored token
17
18
  drop2run init [dir] [--site <subdomain>] Tie this folder to a site
18
19
  drop2run deploy [dir] [--site <subdomain>] Publish a folder
19
20
  drop2run init|deploy --subdomain <name> Create a new site under a name you pick
21
+ drop2run init|deploy --folder <path> File the new site in one of your folders
20
22
  drop2run ls List your sites
23
+ drop2run folders List your folders, as --folder takes them
21
24
  drop2run open [site] Open a site in a browser
22
25
  drop2run rollback <deployId> [--site X] Put an earlier version back live
23
26
  drop2run rm <site> --yes Delete a site and everything on it
@@ -51,6 +54,17 @@ which is not undoable.
51
54
  `--site` never creates anything: it finds a site you already have, and fails when nothing matches. That
52
55
  is what keeps a subdomain typed one letter wrong from quietly becoming a second site.
53
56
 
57
+ `--folder` files the site being created in one of your dashboard folders — by path, as `drop2run folders`
58
+ prints it, or by folder id:
59
+
60
+ ```bash
61
+ drop2run init dist --folder Clients/Acme
62
+ ```
63
+
64
+ Case does not matter. It never creates a folder and never moves an existing site, so it cannot be given
65
+ with `--site`, or to `deploy` in a project whose `drop2run.json` already names a site; a folder that does
66
+ not exist is refused with the list of those that do, before any site is made.
67
+
54
68
  `deploy` never creates a second site while a `drop2run.json` sits next to it — that would leave the real
55
69
  site untouched and you looking at a URL you did not expect.
56
70
 
package/dist/index.js CHANGED
@@ -5,70 +5,6 @@ import { spawn } from "node:child_process";
5
5
  import { randomBytes, createHash } from "node:crypto";
6
6
  import { createServer } from "node:http";
7
7
  import { realpath, readdir, stat, readFile } from "node:fs/promises";
8
- async function call(credentials, path, init2 = {}) {
9
- const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
10
- ...init2,
11
- headers: {
12
- ...init2.headers,
13
- Authorization: `Bearer ${credentials.token}`,
14
- ...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
15
- }
16
- });
17
- if (!response.ok) {
18
- const problem = await response.json().catch(() => null);
19
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
20
- }
21
- return await response.json();
22
- }
23
- async function findSite(credentials, site) {
24
- const wanted = site.trim().toLowerCase();
25
- const found = (await listSites(credentials)).find(
26
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
27
- );
28
- if (found === void 0) {
29
- throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
30
- }
31
- return found;
32
- }
33
- async function deleteSite(credentials, siteId) {
34
- const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
35
- method: "DELETE",
36
- headers: { Authorization: `Bearer ${credentials.token}` }
37
- });
38
- if (!response.ok) {
39
- const problem = await response.json().catch(() => null);
40
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
41
- }
42
- }
43
- async function promoteDeploy(credentials, siteId, deployId) {
44
- return await call(
45
- credentials,
46
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
47
- { method: "POST" }
48
- );
49
- }
50
- async function listTokens(credentials) {
51
- const body = await call(credentials, "tokens");
52
- return body.tokens;
53
- }
54
- async function listSites(credentials) {
55
- const body = await call(credentials, "sites");
56
- return body.sites;
57
- }
58
- async function createSite(credentials, subdomain) {
59
- const created = await call(
60
- credentials,
61
- "sites",
62
- {
63
- method: "POST",
64
- // An absent field rather than a null one, because the API reads null, empty and missing the
65
- // same way and a body carrying only what was asked for is the one that reads correctly in a
66
- // log.
67
- body: JSON.stringify(subdomain === void 0 ? {} : { subdomain })
68
- }
69
- );
70
- return { ...created, name: null };
71
- }
72
8
  const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
73
9
  function configPath() {
74
10
  return join(homedir(), ".config", "drop2run", "config.json");
@@ -161,6 +97,142 @@ function missingCredentialsMessage(surface = "cli") {
161
97
  "The token is shown once when it is created and cannot be recovered afterwards."
162
98
  ].join("\n");
163
99
  }
100
+ async function call(credentials, path, init2 = {}) {
101
+ const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
102
+ ...init2,
103
+ headers: {
104
+ ...init2.headers,
105
+ Authorization: `Bearer ${credentials.token}`,
106
+ ...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
107
+ }
108
+ });
109
+ if (!response.ok) {
110
+ const problem = await response.json().catch(() => null);
111
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
112
+ }
113
+ return await response.json();
114
+ }
115
+ async function findSite(credentials, site) {
116
+ const found = await lookUpSite(credentials, site);
117
+ if (found === void 0) {
118
+ throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
119
+ }
120
+ return found;
121
+ }
122
+ const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
123
+ async function lookUpSite(credentials, site) {
124
+ const wanted = site.trim().toLowerCase();
125
+ if (wanted === "") return void 0;
126
+ const { sites } = await call(
127
+ credentials,
128
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(wanted)}`
129
+ );
130
+ const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
131
+ if (bySubdomain !== void 0) return bySubdomain;
132
+ if (!SITE_ID_SHAPE.test(wanted)) return void 0;
133
+ const response = await fetch(
134
+ `${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
135
+ { headers: { Authorization: `Bearer ${credentials.token}` } }
136
+ );
137
+ if (response.status === 404) return void 0;
138
+ if (!response.ok) {
139
+ const problem = await response.json().catch(() => null);
140
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
141
+ }
142
+ const detail = await response.json();
143
+ return { siteId: detail.siteId, subdomain: detail.subdomain, url: detail.url, name: detail.name };
144
+ }
145
+ async function deleteSite(credentials, siteId) {
146
+ const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
147
+ method: "DELETE",
148
+ headers: { Authorization: `Bearer ${credentials.token}` }
149
+ });
150
+ if (!response.ok) {
151
+ const problem = await response.json().catch(() => null);
152
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
153
+ }
154
+ }
155
+ async function promoteDeploy(credentials, siteId, deployId) {
156
+ return await call(
157
+ credentials,
158
+ `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
159
+ { method: "POST" }
160
+ );
161
+ }
162
+ async function listTokens(credentials) {
163
+ const body = await call(credentials, "tokens");
164
+ return body.tokens;
165
+ }
166
+ const SITES_PER_REQUEST = 100;
167
+ async function listSites(credentials) {
168
+ const body = await call(
169
+ credentials,
170
+ `sites?pageSize=${SITES_PER_REQUEST}`
171
+ );
172
+ return { sites: body.sites, total: body.total };
173
+ }
174
+ async function createSite(credentials, subdomain, folderId) {
175
+ const created = await call(
176
+ credentials,
177
+ "sites",
178
+ {
179
+ method: "POST",
180
+ // Absent fields rather than null ones, because the API reads null, empty and missing the same
181
+ // way and a body carrying only what was asked for is the one that reads correctly in a log.
182
+ body: JSON.stringify({
183
+ ...subdomain === void 0 ? {} : { subdomain },
184
+ ...folderId === void 0 ? {} : { folderId }
185
+ })
186
+ }
187
+ );
188
+ return { ...created, name: null };
189
+ }
190
+ async function listFolders(credentials) {
191
+ const body = await call(credentials, "site-folders");
192
+ const byId = new Map(body.folders.map((folder) => [folder.folderId, folder]));
193
+ const pathOf = (folderId) => {
194
+ const names = [];
195
+ let cursor = byId.get(folderId);
196
+ while (cursor !== void 0 && names.length <= byId.size) {
197
+ names.unshift(cursor.name);
198
+ cursor = cursor.parentId === null ? void 0 : byId.get(cursor.parentId);
199
+ }
200
+ return names.join("/");
201
+ };
202
+ return body.folders.map((folder) => ({ folderId: folder.folderId, path: pathOf(folder.folderId) })).sort((a, b) => a.path.localeCompare(b.path));
203
+ }
204
+ function comparablePath(path) {
205
+ return path.split("/").map((name) => name.trim()).filter((name) => name !== "").join("/").toLowerCase();
206
+ }
207
+ const FOLDERS_LISTED_ON_MISS = 20;
208
+ async function resolveFolder(credentials, folder) {
209
+ const wanted = comparablePath(folder);
210
+ if (wanted === "") {
211
+ throw new Error(
212
+ `"${folder}" does not name a folder. Leave the folder out to put the site at the top level.`
213
+ );
214
+ }
215
+ const folders2 = await listFolders(credentials);
216
+ const byId = folders2.find((candidate) => candidate.folderId.toLowerCase() === wanted);
217
+ if (byId !== void 0) return byId.folderId;
218
+ const matches = folders2.filter((candidate) => comparablePath(candidate.path) === wanted);
219
+ if (matches.length === 1 && matches[0] !== void 0) return matches[0].folderId;
220
+ if (matches.length > 1) {
221
+ throw new Error(
222
+ `More than one folder is at "${folder}", because a folder name can contain "/". Name it by id instead: ${matches.map((match) => match.folderId).join(", ")}.`
223
+ );
224
+ }
225
+ if (folders2.length === 0) {
226
+ throw new Error(
227
+ `No folder of yours is called "${folder}" — this account has no folders yet. Make one in the dashboard at ${dashboardUrlFor(credentials.apiBaseUrl)}, or leave the folder out.`
228
+ );
229
+ }
230
+ const listed = folders2.slice(0, FOLDERS_LISTED_ON_MISS).map((candidate) => candidate.path);
231
+ const more = folders2.length - listed.length;
232
+ throw new Error(
233
+ `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
234
+ );
235
+ }
164
236
  const TIMEOUT_MS = 5 * 60 * 1e3;
165
237
  function newAttempt() {
166
238
  const verifier = randomBytes(32).toString("base64url");
@@ -439,6 +511,485 @@ async function problemToError(response) {
439
511
  const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
440
512
  return new DeployError(code, message, problem);
441
513
  }
514
+ const FILE_TYPES = [
515
+ // Documents. The three extensions that make a folder a documents site.
516
+ {
517
+ ext: ".md",
518
+ mime: "text/markdown; charset=utf-8",
519
+ kind: "markdown",
520
+ document: true,
521
+ reader: true,
522
+ editable: true
523
+ },
524
+ {
525
+ ext: ".markdown",
526
+ mime: "text/markdown; charset=utf-8",
527
+ kind: "markdown",
528
+ document: true,
529
+ reader: true,
530
+ editable: true
531
+ },
532
+ {
533
+ ext: ".pdf",
534
+ mime: "application/pdf",
535
+ kind: "pdf",
536
+ document: true,
537
+ reader: true,
538
+ editable: false
539
+ },
540
+ // Office documents and ebooks. Each has its own kind because each needs its own renderer, loaded
541
+ // only when one is opened — `.docx` through mammoth, `.xlsx` through read-excel-file, `.epub`
542
+ // through foliate-js.
543
+ {
544
+ ext: ".docx",
545
+ mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
546
+ kind: "docx",
547
+ document: true,
548
+ reader: true,
549
+ editable: false
550
+ },
551
+ {
552
+ ext: ".xlsx",
553
+ mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
554
+ kind: "xlsx",
555
+ document: true,
556
+ reader: true,
557
+ editable: false
558
+ },
559
+ {
560
+ ext: ".epub",
561
+ mime: "application/epub+zip",
562
+ kind: "epub",
563
+ document: true,
564
+ reader: true,
565
+ editable: false
566
+ },
567
+ // Prose the reader shows preformatted.
568
+ {
569
+ ext: ".txt",
570
+ mime: "text/plain; charset=utf-8",
571
+ kind: "text",
572
+ document: false,
573
+ reader: true,
574
+ editable: true
575
+ },
576
+ {
577
+ ext: ".log",
578
+ mime: "text/plain; charset=utf-8",
579
+ kind: "text",
580
+ document: false,
581
+ reader: true,
582
+ editable: false
583
+ },
584
+ {
585
+ ext: ".csv",
586
+ mime: "text/csv; charset=utf-8",
587
+ kind: "text",
588
+ document: false,
589
+ reader: true,
590
+ editable: true
591
+ },
592
+ // Structured text. Read through the reader inside a folder of notes, which is why these carry
593
+ // `reader: true` while the source formats below do not.
594
+ {
595
+ ext: ".json",
596
+ mime: "application/json",
597
+ kind: "code",
598
+ document: false,
599
+ reader: true,
600
+ editable: true
601
+ },
602
+ {
603
+ ext: ".yaml",
604
+ mime: "text/yaml; charset=utf-8",
605
+ kind: "code",
606
+ document: false,
607
+ reader: true,
608
+ editable: true
609
+ },
610
+ {
611
+ ext: ".yml",
612
+ mime: "text/yaml; charset=utf-8",
613
+ kind: "code",
614
+ document: false,
615
+ reader: true,
616
+ editable: true
617
+ },
618
+ {
619
+ // No registered media type, so it takes text/plain rather than an invented one. The point is
620
+ // only that a browser shows it instead of downloading it.
621
+ ext: ".toml",
622
+ mime: "text/plain; charset=utf-8",
623
+ kind: "code",
624
+ document: false,
625
+ reader: true,
626
+ editable: true
627
+ },
628
+ // Source the viewer highlights. A link straight to one of these hands over the file.
629
+ {
630
+ ext: ".css",
631
+ mime: "text/css; charset=utf-8",
632
+ kind: "code",
633
+ document: false,
634
+ reader: false,
635
+ editable: true
636
+ },
637
+ {
638
+ ext: ".js",
639
+ mime: "text/javascript; charset=utf-8",
640
+ kind: "code",
641
+ document: false,
642
+ reader: false,
643
+ editable: true
644
+ },
645
+ {
646
+ ext: ".ts",
647
+ mime: null,
648
+ kind: "code",
649
+ document: false,
650
+ reader: false,
651
+ editable: false
652
+ },
653
+ {
654
+ ext: ".tsx",
655
+ mime: null,
656
+ kind: "code",
657
+ document: false,
658
+ reader: false,
659
+ editable: false
660
+ },
661
+ {
662
+ ext: ".jsx",
663
+ mime: null,
664
+ kind: "code",
665
+ document: false,
666
+ reader: false,
667
+ editable: false
668
+ },
669
+ {
670
+ ext: ".sh",
671
+ mime: null,
672
+ kind: "code",
673
+ document: false,
674
+ reader: false,
675
+ editable: false
676
+ },
677
+ // The languages an assistant writes most, beyond the web's own. Same row as `.ts`: shown as source
678
+ // when somebody opens one in the reader or publishes it on its own, handed over as a file when a
679
+ // link names it directly, and no media type — adding one changes what a browser does with a
680
+ // published file, which I10 makes a decision of its own rather than a side effect of this list.
681
+ {
682
+ ext: ".py",
683
+ mime: null,
684
+ kind: "code",
685
+ document: false,
686
+ reader: false,
687
+ editable: false
688
+ },
689
+ {
690
+ ext: ".sql",
691
+ mime: null,
692
+ kind: "code",
693
+ document: false,
694
+ reader: false,
695
+ editable: false
696
+ },
697
+ {
698
+ ext: ".go",
699
+ mime: null,
700
+ kind: "code",
701
+ document: false,
702
+ reader: false,
703
+ editable: false
704
+ },
705
+ {
706
+ ext: ".rs",
707
+ mime: null,
708
+ kind: "code",
709
+ document: false,
710
+ reader: false,
711
+ editable: false
712
+ },
713
+ {
714
+ ext: ".java",
715
+ mime: null,
716
+ kind: "code",
717
+ document: false,
718
+ reader: false,
719
+ editable: false
720
+ },
721
+ {
722
+ ext: ".kt",
723
+ mime: null,
724
+ kind: "code",
725
+ document: false,
726
+ reader: false,
727
+ editable: false
728
+ },
729
+ {
730
+ ext: ".swift",
731
+ mime: null,
732
+ kind: "code",
733
+ document: false,
734
+ reader: false,
735
+ editable: false
736
+ },
737
+ {
738
+ ext: ".rb",
739
+ mime: null,
740
+ kind: "code",
741
+ document: false,
742
+ reader: false,
743
+ editable: false
744
+ },
745
+ {
746
+ ext: ".php",
747
+ mime: null,
748
+ kind: "code",
749
+ document: false,
750
+ reader: false,
751
+ editable: false
752
+ },
753
+ {
754
+ ext: ".c",
755
+ mime: null,
756
+ kind: "code",
757
+ document: false,
758
+ reader: false,
759
+ editable: false
760
+ },
761
+ {
762
+ ext: ".h",
763
+ mime: null,
764
+ kind: "code",
765
+ document: false,
766
+ reader: false,
767
+ editable: false
768
+ },
769
+ {
770
+ ext: ".cpp",
771
+ mime: null,
772
+ kind: "code",
773
+ document: false,
774
+ reader: false,
775
+ editable: false
776
+ },
777
+ {
778
+ ext: ".cs",
779
+ mime: null,
780
+ kind: "code",
781
+ document: false,
782
+ reader: false,
783
+ editable: false
784
+ },
785
+ {
786
+ ext: ".r",
787
+ mime: null,
788
+ kind: "code",
789
+ document: false,
790
+ reader: false,
791
+ editable: false
792
+ },
793
+ {
794
+ ext: ".xml",
795
+ mime: "application/xml",
796
+ kind: "code",
797
+ document: false,
798
+ reader: false,
799
+ editable: true
800
+ },
801
+ // Pictures, shown with an img element.
802
+ {
803
+ ext: ".png",
804
+ mime: "image/png",
805
+ kind: "image",
806
+ document: false,
807
+ reader: false,
808
+ editable: false
809
+ },
810
+ {
811
+ ext: ".jpg",
812
+ mime: "image/jpeg",
813
+ kind: "image",
814
+ document: false,
815
+ reader: false,
816
+ editable: false
817
+ },
818
+ {
819
+ ext: ".jpeg",
820
+ mime: "image/jpeg",
821
+ kind: "image",
822
+ document: false,
823
+ reader: false,
824
+ editable: false
825
+ },
826
+ {
827
+ ext: ".gif",
828
+ mime: "image/gif",
829
+ kind: "image",
830
+ document: false,
831
+ reader: false,
832
+ editable: false
833
+ },
834
+ {
835
+ ext: ".webp",
836
+ mime: "image/webp",
837
+ kind: "image",
838
+ document: false,
839
+ reader: false,
840
+ editable: false
841
+ },
842
+ {
843
+ ext: ".avif",
844
+ mime: "image/avif",
845
+ kind: "image",
846
+ document: false,
847
+ reader: false,
848
+ editable: false
849
+ },
850
+ {
851
+ // Text, hand-edited in practice, and script-carrying. Editable anyway: the Worker derives the
852
+ // type at serve time (I10) and already serves whatever was uploaded, so withholding the edit
853
+ // would withhold nothing else.
854
+ ext: ".svg",
855
+ mime: "image/svg+xml",
856
+ kind: "image",
857
+ document: false,
858
+ reader: false,
859
+ editable: true
860
+ },
861
+ {
862
+ ext: ".ico",
863
+ mime: "image/x-icon",
864
+ kind: "image",
865
+ document: false,
866
+ reader: false,
867
+ editable: false
868
+ },
869
+ // A customer's own pages. Never rendered inside the reader.
870
+ {
871
+ ext: ".html",
872
+ mime: "text/html; charset=utf-8",
873
+ kind: "html",
874
+ document: false,
875
+ reader: false,
876
+ editable: true
877
+ },
878
+ {
879
+ // ⚠️ No media type, and that is the state of the code rather than a decision: `TYPES` in
880
+ // `headers.ts` has never held `htm`, so a published `.htm` is served as octet-stream and
881
+ // downloads instead of rendering. Recorded here rather than fixed, because this table was
882
+ // introduced as a refactor and a refactor that changes what a URL answers is not one.
883
+ ext: ".htm",
884
+ mime: null,
885
+ kind: "html",
886
+ document: false,
887
+ reader: false,
888
+ editable: true
889
+ },
890
+ // Everything below renders as nothing — the viewer offers a download — but still needs a media
891
+ // type, because these are the files a static site is built out of.
892
+ {
893
+ ext: ".mjs",
894
+ mime: "text/javascript; charset=utf-8",
895
+ kind: "binary",
896
+ document: false,
897
+ reader: false,
898
+ editable: true
899
+ },
900
+ {
901
+ ext: ".cjs",
902
+ mime: null,
903
+ kind: "binary",
904
+ document: false,
905
+ reader: false,
906
+ editable: true
907
+ },
908
+ {
909
+ ext: ".map",
910
+ mime: "application/json",
911
+ kind: "binary",
912
+ document: false,
913
+ reader: false,
914
+ editable: false
915
+ },
916
+ {
917
+ ext: ".wasm",
918
+ mime: "application/wasm",
919
+ kind: "binary",
920
+ document: false,
921
+ reader: false,
922
+ editable: false
923
+ },
924
+ {
925
+ ext: ".woff2",
926
+ mime: "font/woff2",
927
+ kind: "binary",
928
+ document: false,
929
+ reader: false,
930
+ editable: false
931
+ },
932
+ {
933
+ ext: ".woff",
934
+ mime: "font/woff",
935
+ kind: "binary",
936
+ document: false,
937
+ reader: false,
938
+ editable: false
939
+ },
940
+ {
941
+ ext: ".ttf",
942
+ mime: "font/ttf",
943
+ kind: "binary",
944
+ document: false,
945
+ reader: false,
946
+ editable: false
947
+ },
948
+ {
949
+ ext: ".mp4",
950
+ mime: "video/mp4",
951
+ kind: "binary",
952
+ document: false,
953
+ reader: false,
954
+ editable: false
955
+ },
956
+ {
957
+ ext: ".webm",
958
+ mime: "video/webm",
959
+ kind: "binary",
960
+ document: false,
961
+ reader: false,
962
+ editable: false
963
+ }
964
+ ];
965
+ function extensionsWhere(include) {
966
+ return FILE_TYPES.filter(include).map((type) => type.ext);
967
+ }
968
+ const DOCUMENT_EXTENSIONS$1 = extensionsWhere((type) => type.document);
969
+ const VIEWABLE_EXTENSIONS$1 = extensionsWhere(
970
+ (type) => type.kind !== "binary"
971
+ );
972
+ extensionsWhere((type) => type.reader);
973
+ extensionsWhere((type) => type.editable);
974
+ const WEB_ASSET_EXTENSIONS = [
975
+ ".html",
976
+ ".htm",
977
+ ".js",
978
+ ".mjs",
979
+ ".cjs",
980
+ ".css"
981
+ ];
982
+ const GALLERY_EXTENSIONS = extensionsWhere(
983
+ (type) => type.kind === "image"
984
+ );
985
+ Object.fromEntries(
986
+ FILE_TYPES.filter((type) => type.mime !== null).map((type) => [type.ext, type.mime])
987
+ );
988
+ function extensionOf(path) {
989
+ const name = path.slice(path.lastIndexOf("/") + 1);
990
+ const dot = name.lastIndexOf(".");
991
+ return dot <= 0 ? "" : name.slice(dot).toLowerCase();
992
+ }
442
993
  async function sha256Hex(bytes) {
443
994
  const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
444
995
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -470,52 +1021,29 @@ function shouldIgnore(path) {
470
1021
  return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
471
1022
  }
472
1023
  const REQUIRED_INDEX = "index.html";
473
- const DOCUMENT_EXTENSIONS = [".md", ".markdown", ".pdf"];
1024
+ const DOCUMENT_EXTENSIONS = DOCUMENT_EXTENSIONS$1;
474
1025
  function isDocumentPath(path) {
475
1026
  const lower = path.toLowerCase();
476
1027
  return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
477
1028
  }
478
- const VIEWABLE_EXTENSIONS = [
479
- // Documents — these also count at any depth, via DOCUMENT_EXTENSIONS.
480
- ".md",
481
- ".markdown",
482
- ".pdf",
483
- // Prose the reader shows preformatted.
484
- ".txt",
485
- ".log",
486
- ".csv",
487
- // Source the reader shows with highlighting.
488
- ".json",
489
- ".yaml",
490
- ".yml",
491
- ".toml",
492
- ".css",
493
- ".js",
494
- ".ts",
495
- ".tsx",
496
- ".jsx",
497
- ".sh",
498
- ".xml",
499
- // Pictures the reader shows with an img element.
500
- ".png",
501
- ".jpg",
502
- ".jpeg",
503
- ".gif",
504
- ".webp",
505
- ".avif",
506
- ".svg",
507
- ".ico",
508
- // See the note above: a floor for a caller that did not rename it.
509
- ".html",
510
- ".htm"
511
- ];
1029
+ const VIEWABLE_EXTENSIONS = VIEWABLE_EXTENSIONS$1;
512
1030
  function isViewablePath(path) {
513
1031
  const lower = path.toLowerCase();
514
1032
  return VIEWABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
515
1033
  }
1034
+ function isImageFolder(paths) {
1035
+ let pictures = false;
1036
+ for (const path of paths) {
1037
+ const ext = extensionOf(path);
1038
+ if (WEB_ASSET_EXTENSIONS.includes(ext)) return false;
1039
+ if (GALLERY_EXTENSIONS.includes(ext)) pictures = true;
1040
+ }
1041
+ return pictures;
1042
+ }
516
1043
  function canPublish(paths) {
517
1044
  if (paths.some((path) => path === REQUIRED_INDEX)) return true;
518
1045
  if (paths.some(isDocumentPath)) return true;
1046
+ if (isImageFolder(paths)) return true;
519
1047
  return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
520
1048
  }
521
1049
  function renameOfLoneHtmlPage(paths) {
@@ -544,37 +1072,42 @@ function checkLimits(files, limits) {
544
1072
  `A site needs an ${REQUIRED_INDEX} at its top level, or at least one .md or .pdf file to publish as a documents site — or be a single file that can be displayed on its own, such as a note, a PDF, a page or an image. Drop the folder that contains it, not the folder above.`
545
1073
  );
546
1074
  }
547
- if (!limits) return;
1075
+ const refusal = sizeRefusal(
1076
+ files.map((file) => ({ path: file.path, size: file.bytes.length })),
1077
+ limits
1078
+ );
1079
+ if (refusal) throw refusal;
1080
+ }
1081
+ function sizeRefusal(files, limits) {
1082
+ if (!limits) return null;
548
1083
  if (files.length > limits.maxFiles) {
549
- throw new DeployError(
1084
+ return new DeployError(
550
1085
  ClientErrorCode.TooManyFiles,
551
1086
  `That is ${files.length} files, and this plan allows ${limits.maxFiles}.`,
552
1087
  { limit: limits.maxFiles, actual: files.length }
553
1088
  );
554
1089
  }
555
- const oversized = files.find((file) => file.bytes.length > limits.maxFileBytes);
1090
+ const oversized = files.find((file) => file.size > limits.maxFileBytes);
556
1091
  if (oversized) {
557
- throw new DeployError(
1092
+ return new DeployError(
558
1093
  ClientErrorCode.FileTooLarge,
559
- `${oversized.path} is ${formatBytes$1(oversized.bytes.length)}, and this plan allows ${formatBytes$1(limits.maxFileBytes)} per file.`,
1094
+ `${oversized.path} is ${formatBytes$1(oversized.size)}, and this plan allows ${formatBytes$1(limits.maxFileBytes)} per file.`,
560
1095
  {
561
1096
  path: oversized.path,
562
1097
  limit: limits.maxFileBytes,
563
- actual: oversized.bytes.length
1098
+ actual: oversized.size
564
1099
  }
565
1100
  );
566
1101
  }
567
- const total = totalBytes(files);
1102
+ const total = files.reduce((sum, file) => sum + file.size, 0);
568
1103
  if (limits.maxSiteBytes !== null && total > limits.maxSiteBytes) {
569
- throw new DeployError(
1104
+ return new DeployError(
570
1105
  ClientErrorCode.TooLarge,
571
1106
  `That drop is ${formatBytes$1(total)}, and this plan allows ${formatBytes$1(limits.maxSiteBytes)}.`,
572
1107
  { limit: limits.maxSiteBytes, actual: total }
573
1108
  );
574
1109
  }
575
- }
576
- function totalBytes(files) {
577
- return files.reduce((sum, file) => sum + file.bytes.length, 0);
1110
+ return null;
578
1111
  }
579
1112
  const MAX_NAME_LENGTH = 60;
580
1113
  const HEAD_BYTES = 64 * 1024;
@@ -873,20 +1406,23 @@ function escapesRoot(root, candidate) {
873
1406
  const inside = relative(root, candidate);
874
1407
  return inside === "" || inside.startsWith("..");
875
1408
  }
876
- async function resolveSite(credentials, site, subdomain) {
877
- if (subdomain !== void 0) {
878
- if (site !== void 0) {
879
- throw new Error(
880
- "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
881
- );
882
- }
883
- return await createSite(credentials, subdomain);
1409
+ async function resolveSite(credentials, site, newSite) {
1410
+ const { subdomain, folder } = newSite;
1411
+ if (site !== void 0 && subdomain !== void 0) {
1412
+ throw new Error(
1413
+ "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1414
+ );
884
1415
  }
885
- if (site === void 0) return await createSite(credentials);
886
- const wanted = site.trim().toLowerCase();
887
- const found = (await listSites(credentials)).find(
888
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
889
- );
1416
+ if (site !== void 0 && folder !== void 0) {
1417
+ throw new Error(
1418
+ "A folder only says where a new site goes, so it cannot be given with an existing site. Leave the site out to create one in that folder, or move the existing site from the dashboard."
1419
+ );
1420
+ }
1421
+ if (site === void 0) {
1422
+ const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
1423
+ return await createSite(credentials, subdomain, folderId);
1424
+ }
1425
+ const found = await lookUpSite(credentials, site);
890
1426
  if (found === void 0) {
891
1427
  throw new Error(
892
1428
  `No site of yours is called "${site}". Leave the site out to publish to a new one, or list your sites to see what exists.`
@@ -894,8 +1430,8 @@ async function resolveSite(credentials, site, subdomain) {
894
1430
  }
895
1431
  return found;
896
1432
  }
897
- async function publish(credentials, source, site, onProgress, subdomain) {
898
- const target = await resolveSite(credentials, site, subdomain);
1433
+ async function publish(credentials, source, site, onProgress, newSite = {}) {
1434
+ const target = await resolveSite(credentials, site, newSite);
899
1435
  let files = 0;
900
1436
  let url = target.url;
901
1437
  let unchanged = false;
@@ -926,11 +1462,11 @@ async function publish(credentials, source, site, onProgress, subdomain) {
926
1462
  });
927
1463
  return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
928
1464
  }
929
- async function publishDirectory(credentials, directory, site, onProgress, subdomain) {
1465
+ async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
930
1466
  const found = await stat(directory).catch(() => null);
931
1467
  if (found === null) throw new Error(`There is no folder at ${directory}.`);
932
1468
  if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
933
- return await publish(credentials, directorySource(directory), site, onProgress, subdomain);
1469
+ return await publish(credentials, directorySource(directory), site, onProgress, newSite);
934
1470
  }
935
1471
  const silentProgress = Object.assign(() => {
936
1472
  }, { done: () => {
@@ -1127,33 +1663,67 @@ API: ${found.credentials.apiBaseUrl}`,
1127
1663
  async function list() {
1128
1664
  const found = credentialsOr();
1129
1665
  if ("result" in found) return found.result;
1130
- const sites = await listSites(found.credentials);
1666
+ const { sites, total } = await listSites(found.credentials);
1131
1667
  if (sites.length === 0) {
1132
1668
  return {
1133
1669
  text: "No sites yet. `drop2run deploy` publishes one.",
1134
- json: { sites: [] },
1670
+ json: { sites: [], total: 0 },
1135
1671
  code: 0
1136
1672
  };
1137
1673
  }
1674
+ const lines = sites.map(
1675
+ (site) => `${site.subdomain} ${site.url}${site.name ? ` ${site.name}` : ""}`
1676
+ );
1677
+ if (total > sites.length) {
1678
+ lines.push(
1679
+ `… and ${(total - sites.length).toLocaleString()} more — this lists the newest ${sites.length.toLocaleString()} of ${total.toLocaleString()}.`
1680
+ );
1681
+ }
1138
1682
  return {
1139
- text: sites.map((site) => `${site.subdomain} ${site.url}${site.name ? ` ${site.name}` : ""}`).join("\n"),
1140
- json: { sites },
1683
+ text: lines.join("\n"),
1684
+ json: { sites, total },
1141
1685
  code: 0
1142
1686
  };
1143
1687
  }
1144
- async function deployCommand(directory, site, report = silentProgress, subdomain) {
1688
+ async function folders() {
1689
+ const found = credentialsOr();
1690
+ if ("result" in found) return found.result;
1691
+ try {
1692
+ const all = await listFolders(found.credentials);
1693
+ if (all.length === 0) {
1694
+ return {
1695
+ text: `No folders yet. Make one in the dashboard at ${dashboardUrlFor(found.credentials.apiBaseUrl)}, then file a new site in it with \`--folder\`.`,
1696
+ json: { folders: [] },
1697
+ code: 0
1698
+ };
1699
+ }
1700
+ return {
1701
+ text: all.map((folder) => `${folder.path} ${folder.folderId}`).join("\n"),
1702
+ json: { folders: all },
1703
+ code: 0
1704
+ };
1705
+ } catch (error) {
1706
+ return failure(error instanceof Error ? error.message : String(error));
1707
+ }
1708
+ }
1709
+ async function deployCommand(directory, site, report = silentProgress, newSite = {}) {
1145
1710
  const found = credentialsOr();
1146
1711
  if ("result" in found) return found.result;
1147
1712
  const project = readProject();
1148
1713
  const folder = directory ?? project?.dir ?? ".";
1149
- const target = subdomain === void 0 ? site ?? project?.siteId : void 0;
1714
+ const target = newSite.subdomain === void 0 ? site ?? project?.siteId : void 0;
1715
+ if (newSite.folder !== void 0 && target !== void 0 && project !== null) {
1716
+ return failure(
1717
+ `This folder's ${PROJECT_FILE} already publishes to ${project.subdomain || project.siteId}, and \`--folder\` only files a new site. Move that site from the dashboard, or add \`--subdomain\` to create a new one in the folder.`
1718
+ );
1719
+ }
1150
1720
  try {
1151
1721
  const result = await publishDirectory(
1152
1722
  found.credentials,
1153
1723
  resolve(folder),
1154
1724
  target,
1155
1725
  report,
1156
- subdomain
1726
+ newSite
1157
1727
  );
1158
1728
  const what = result.unchanged ? "Already up to date — nothing needed publishing." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
1159
1729
  return {
@@ -1168,7 +1738,7 @@ ${result.url}`,
1168
1738
  report.done();
1169
1739
  }
1170
1740
  }
1171
- async function init(directory, site, subdomain) {
1741
+ async function init(directory, site, newSite = {}) {
1172
1742
  const found = credentialsOr();
1173
1743
  if ("result" in found) return found.result;
1174
1744
  if (readProject() !== null) {
@@ -1177,7 +1747,11 @@ async function init(directory, site, subdomain) {
1177
1747
  );
1178
1748
  }
1179
1749
  try {
1180
- const target = site === void 0 ? await createSite(found.credentials, subdomain) : await findSite(found.credentials, site);
1750
+ const target = site === void 0 ? await createSite(
1751
+ found.credentials,
1752
+ newSite.subdomain,
1753
+ newSite.folder === void 0 ? void 0 : await resolveFolder(found.credentials, newSite.folder)
1754
+ ) : await findSite(found.credentials, site);
1181
1755
  const project = {
1182
1756
  siteId: target.siteId,
1183
1757
  subdomain: target.subdomain,
@@ -1316,7 +1890,9 @@ const HELP = `drop2run — publish a static site from the command line
1316
1890
  drop2run init [dir] [--site <subdomain>] Tie this folder to a site (writes drop2run.json)
1317
1891
  drop2run deploy [dir] [--site <subdomain>] Publish a folder (default: . or drop2run.json)
1318
1892
  drop2run init|deploy --subdomain <name> Create a new site under a name you pick
1893
+ drop2run init|deploy --folder <path> File the new site in one of your folders
1319
1894
  drop2run ls List your sites
1895
+ drop2run folders List your folders, as --folder takes them
1320
1896
  drop2run open [site] Open a site in a browser
1321
1897
  drop2run rollback <deployId> [--site X] Put an earlier version back live
1322
1898
  drop2run rm <site> --yes Delete a site and everything on it
@@ -1329,6 +1905,8 @@ Flags
1329
1905
  --json Print machine-readable output instead of text
1330
1906
  --site X Act on an existing site rather than the one in drop2run.json
1331
1907
  --subdomain X Create a new site under this name (init and deploy only)
1908
+ --folder X File the new site in this folder: a path such as Clients/Acme,
1909
+ or a folder id (init and deploy only)
1332
1910
  --device Sign in by approving a code on another machine
1333
1911
  --yes Confirm a deletion, which cannot be undone
1334
1912
 
@@ -1340,6 +1918,10 @@ Where a site comes from
1340
1918
  --site only ever finds a site you already have; it never creates one, so a
1341
1919
  subdomain typed wrong fails instead of quietly becoming a second site.
1342
1920
 
1921
+ --folder only applies to a site being created. It never creates a folder
1922
+ and never moves an existing site; make and rearrange folders in the
1923
+ dashboard.
1924
+
1343
1925
  Signing in
1344
1926
  \`login\` opens a browser and listens on 127.0.0.1, so it needs both on this
1345
1927
  machine. Over SSH, in a container or under WSL, use \`login --device\`: it
@@ -1355,7 +1937,7 @@ function flagValue(args, flag) {
1355
1937
  const value = args[at + 1];
1356
1938
  return value === void 0 || value.startsWith("-") ? void 0 : value;
1357
1939
  }
1358
- const VALUE_FLAGS = ["--site", "--subdomain"];
1940
+ const VALUE_FLAGS = ["--site", "--subdomain", "--folder"];
1359
1941
  function positionals(argv) {
1360
1942
  const found = [];
1361
1943
  for (let at = 0; at < argv.length; at++) {
@@ -1370,16 +1952,29 @@ function positionals(argv) {
1370
1952
  }
1371
1953
  return found;
1372
1954
  }
1373
- function subdomainFlagProblem(argv, command, site, subdomain) {
1374
- if (!argv.includes("--subdomain")) return void 0;
1375
- if (subdomain === void 0) {
1376
- return "`--subdomain` needs a name after it, for example `--subdomain my-docs`.";
1955
+ const SUBDOMAIN_FLAG = {
1956
+ flag: "--subdomain",
1957
+ example: "my-docs",
1958
+ effect: "`--subdomain` creates a new one",
1959
+ elsewhere: (command) => ` Use \`--site\` to tell \`${command}\` which of your sites to act on.`
1960
+ };
1961
+ const FOLDER_FLAG = {
1962
+ flag: "--folder",
1963
+ example: "Clients/Acme",
1964
+ effect: "`--folder` only files a new one — move an existing site from the dashboard",
1965
+ elsewhere: (command) => command === "ls" || command === "list" ? " `drop2run folders` lists them." : ""
1966
+ };
1967
+ function newSiteFlagProblem(argv, command, site, value, described) {
1968
+ const { flag, example, effect, elsewhere } = described;
1969
+ if (!argv.includes(flag)) return void 0;
1970
+ if (value === void 0) {
1971
+ return `\`${flag}\` needs a value after it, for example \`${flag} ${example}\`.`;
1377
1972
  }
1378
1973
  if (site !== void 0) {
1379
- return "`--site` and `--subdomain` cannot both be given: `--site` publishes to a site you already have, and `--subdomain` creates a new one. Drop whichever is not what you meant.";
1974
+ return `\`--site\` and \`${flag}\` cannot both be given: \`--site\` publishes to a site you already have, and ${effect}. Drop whichever is not what you meant.`;
1380
1975
  }
1381
1976
  if (command !== "init" && command !== "deploy") {
1382
- return `\`--subdomain\` only applies to \`init\` and \`deploy\`, which can create a site. Use \`--site\` to tell \`${command}\` which of your sites to act on.`;
1977
+ return `\`${flag}\` only applies to \`init\` and \`deploy\`, which can create a site.` + elsewhere(command);
1383
1978
  }
1384
1979
  return void 0;
1385
1980
  }
@@ -1388,7 +1983,7 @@ async function run(argv) {
1388
1983
  const positional = positionals(argv);
1389
1984
  const [command, ...rest] = positional;
1390
1985
  if (argv.includes("--version")) {
1391
- const { version } = await import("./package-sLisZctX.js").then(
1986
+ const { version } = await import("./package-bzaCMW_E.js").then(
1392
1987
  (module) => module.default
1393
1988
  );
1394
1989
  return { text: version, json: { version }, code: 0 };
@@ -1398,7 +1993,8 @@ async function run(argv) {
1398
1993
  if (command === void 0) return { text: HELP, json: { help: HELP }, code: 1 };
1399
1994
  const site = flagValue(argv, "--site");
1400
1995
  const subdomain = flagValue(argv, "--subdomain");
1401
- const refusal = subdomainFlagProblem(argv, command, site, subdomain);
1996
+ const folder = flagValue(argv, "--folder");
1997
+ const refusal = newSiteFlagProblem(argv, command, site, subdomain, SUBDOMAIN_FLAG) ?? newSiteFlagProblem(argv, command, site, folder, FOLDER_FLAG);
1402
1998
  if (refusal !== void 0) {
1403
1999
  const failed = { text: refusal, json: { error: refusal }, code: 1 };
1404
2000
  return json ? { ...failed, text: JSON.stringify(failed.json, null, 2) } : failed;
@@ -1410,11 +2006,11 @@ async function run(argv) {
1410
2006
  json,
1411
2007
  argv.includes("--device"),
1412
2008
  argv.includes("--yes"),
1413
- subdomain
2009
+ { subdomain, folder }
1414
2010
  );
1415
2011
  return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
1416
2012
  }
1417
- async function dispatch(command, rest, site, json, device, confirmed, subdomain) {
2013
+ async function dispatch(command, rest, site, json, device, confirmed, newSite = {}) {
1418
2014
  switch (command) {
1419
2015
  case "login": {
1420
2016
  const print = json ? () => {
@@ -1424,18 +2020,20 @@ async function dispatch(command, rest, site, json, device, confirmed, subdomain)
1424
2020
  case "logout":
1425
2021
  return logout();
1426
2022
  case "init":
1427
- return await init(rest[0] ?? ".", site, subdomain);
2023
+ return await init(rest[0] ?? ".", site, newSite);
1428
2024
  case "deploy": {
1429
2025
  const report = json ? silentProgress : progressWriter(
1430
2026
  (text) => process.stderr.write(text),
1431
2027
  process.stderr.isTTY === true,
1432
2028
  process.stderr.columns ?? 80
1433
2029
  );
1434
- return await deployCommand(rest[0], site, report, subdomain);
2030
+ return await deployCommand(rest[0], site, report, newSite);
1435
2031
  }
1436
2032
  case "ls":
1437
2033
  case "list":
1438
2034
  return await list();
2035
+ case "folders":
2036
+ return await folders();
1439
2037
  case "open":
1440
2038
  return await open(rest[0] ?? site);
1441
2039
  case "rollback":
@@ -1,6 +1,6 @@
1
1
  const name = "drop2run";
2
- const version = "0.4.1";
3
- const description = "Publish a static site to Drop2Run from the command line.";
2
+ const version = "0.6.0";
3
+ const description = "Publish a page, a folder of documents or a built site to Drop2Run from the command line.";
4
4
  const license = "MIT";
5
5
  const type = "module";
6
6
  const bin = { "drop2run": "bin/drop2run.mjs" };
@@ -10,7 +10,7 @@ const publishConfig = { "access": "public" };
10
10
  const homepage = "https://dropto.run";
11
11
  const repository = { "type": "git", "url": "git+https://github.com/Drop-to-run/drop2run-cli.git" };
12
12
  const bugs = { "url": "https://github.com/Drop-to-run/drop2run-cli/issues" };
13
- const keywords = ["static-site", "deploy", "hosting", "cli", "ai", "claude", "agent", "llm"];
13
+ const keywords = ["static-site", "markdown", "pdf", "deploy", "hosting", "cli", "ai", "claude", "agent", "llm"];
14
14
  const scripts = { "prepublishOnly": "npm run build && npm test", "build": "vite build", "typecheck": "tsc --noEmit", "test": "vitest run", "lint": "biome check .", "format": "biome check --write ." };
15
15
  const devDependencies = { "@biomejs/biome": "^2.5.11", "@types/node": "^26.4.0", "typescript": "^5.9.3", "vite": "^7.2.1", "vitest": "^4.1.11" };
16
16
  const _package = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "drop2run",
3
- "version": "0.4.1",
4
- "description": "Publish a static site to Drop2Run from the command line.",
3
+ "version": "0.6.0",
4
+ "description": "Publish a page, a folder of documents or a built site to Drop2Run from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -27,6 +27,8 @@
27
27
  },
28
28
  "keywords": [
29
29
  "static-site",
30
+ "markdown",
31
+ "pdf",
30
32
  "deploy",
31
33
  "hosting",
32
34
  "cli",