drop2run 0.0.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/LICENSE +21 -0
- package/README.md +62 -0
- package/bin/drop2run.mjs +11 -0
- package/dist/index.js +717 -0
- package/dist/package-VuVin5yv.js +44 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Drop2Run
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# drop2run
|
|
2
|
+
|
|
3
|
+
Publish a static site to [Drop2Run](https://dropto.run) from the command line.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
drop2run deploy [dir] [--site <subdomain>] Publish a folder (default: .)
|
|
7
|
+
drop2run ls List your sites
|
|
8
|
+
drop2run whoami Check the token and whose it is
|
|
9
|
+
drop2run where Show which token source is in use
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`--json` on any command prints machine-readable output instead of text.
|
|
13
|
+
|
|
14
|
+
## Signing in
|
|
15
|
+
|
|
16
|
+
**There is no `drop2run login` yet.** It needs endpoints the API does not have — the loopback PKCE flow
|
|
17
|
+
in §4 of `docs/briefs/DEVTOOLS-BRIEF.md` — and a `login` that printed "not implemented" would be worse
|
|
18
|
+
than none, so the command does not exist rather than existing and lying.
|
|
19
|
+
|
|
20
|
+
Until then, create a token at <https://dropto.run/account/tokens> and either set it in the environment:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
DROP2RUN_TOKEN=d2r_...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
or put it in `~/.config/drop2run/config.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{ "token": "d2r_..." }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The environment wins. That is the same file and the same precedence `@drop2run/mcp` uses, so signing in
|
|
33
|
+
once covers both.
|
|
34
|
+
|
|
35
|
+
`drop2run where` says which source is in force without ever printing the token. That is deliberate: the
|
|
36
|
+
output of a command line ends up in issue reports, terminal recordings and CI logs, and "why is it using
|
|
37
|
+
the wrong account" is answerable without showing the secret.
|
|
38
|
+
|
|
39
|
+
## What `token create` is not
|
|
40
|
+
|
|
41
|
+
The brief's §5 lists `token create|list|revoke`. **Create and revoke cannot exist here**, and that is a
|
|
42
|
+
decision rather than an omission: a token that can mint tokens is not a leaked credential but a permanent
|
|
43
|
+
one — whoever takes it makes a second, and revoking the first changes nothing because the replacement is
|
|
44
|
+
one the owner never made and will not recognise. Both endpoints require a browser session. `gh` and
|
|
45
|
+
`vercel` draw the line in the same place.
|
|
46
|
+
|
|
47
|
+
## Not published yet
|
|
48
|
+
|
|
49
|
+
`private: true`, because P2 is not finished: without `login` this is a tool that installs and then asks
|
|
50
|
+
you to go and paste a token by hand. The npm name `drop2run` is still unheld — a known risk, recorded in
|
|
51
|
+
§8 of the brief.
|
|
52
|
+
|
|
53
|
+
The bundle itself would work. `@drop2run/core` and `@drop2run/node` are resolved by build aliases rather
|
|
54
|
+
than installed, and `vite build` folds both into `dist/index.js`, so the tarball has no import pointing
|
|
55
|
+
at something npm cannot fetch. What is missing is a reason to release, not a way to.
|
|
56
|
+
|
|
57
|
+
## Running it from a checkout
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cd packages/cli && npm run build
|
|
61
|
+
node bin/drop2run.mjs --help
|
|
62
|
+
```
|
package/bin/drop2run.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Entry point for `drop2run`.
|
|
4
|
+
*
|
|
5
|
+
* Thin on purpose: the shebang and the executable bit are the two things that have to survive being
|
|
6
|
+
* published, and `.gitignore` has already eaten this directory once — see the commit that added the
|
|
7
|
+
* negation for bin directories under packages.
|
|
8
|
+
*/
|
|
9
|
+
import { main } from "../dist/index.js";
|
|
10
|
+
|
|
11
|
+
await main(process.argv.slice(2));
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import { join, relative, sep, resolve } from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { realpath, readdir, stat, readFile } from "node:fs/promises";
|
|
5
|
+
async function call(credentials, path, init = {}) {
|
|
6
|
+
const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
|
|
7
|
+
...init,
|
|
8
|
+
headers: {
|
|
9
|
+
...init.headers,
|
|
10
|
+
Authorization: `Bearer ${credentials.token}`,
|
|
11
|
+
...init.body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
const problem = await response.json().catch(() => null);
|
|
16
|
+
throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
|
|
17
|
+
}
|
|
18
|
+
return await response.json();
|
|
19
|
+
}
|
|
20
|
+
async function listSites(credentials) {
|
|
21
|
+
const body = await call(credentials, "sites");
|
|
22
|
+
return body.sites;
|
|
23
|
+
}
|
|
24
|
+
async function createSite(credentials) {
|
|
25
|
+
const created = await call(
|
|
26
|
+
credentials,
|
|
27
|
+
"sites",
|
|
28
|
+
{ method: "POST", body: JSON.stringify({}) }
|
|
29
|
+
);
|
|
30
|
+
return { ...created, name: null };
|
|
31
|
+
}
|
|
32
|
+
const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
|
|
33
|
+
function configPath() {
|
|
34
|
+
return join(homedir(), ".config", "drop2run", "config.json");
|
|
35
|
+
}
|
|
36
|
+
const DEFAULT_API_BASE_URL = "https://dropto.run/api";
|
|
37
|
+
function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
|
|
38
|
+
let stored = {};
|
|
39
|
+
try {
|
|
40
|
+
stored = JSON.parse(read(configPath()));
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
const token = env[TOKEN_VARIABLE]?.trim() || stored.token?.trim();
|
|
44
|
+
if (!token) return null;
|
|
45
|
+
return {
|
|
46
|
+
token,
|
|
47
|
+
apiBaseUrl: env.DROP2RUN_API_URL?.trim() || stored.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function missingCredentialsMessage() {
|
|
51
|
+
return [
|
|
52
|
+
"No Drop2Run access token.",
|
|
53
|
+
"",
|
|
54
|
+
"Create one at https://dropto.run/account/tokens, then either:",
|
|
55
|
+
` - set ${TOKEN_VARIABLE} in the environment, or`,
|
|
56
|
+
` - put it in ${configPath()} as {"token": "d2r_..."}`,
|
|
57
|
+
"",
|
|
58
|
+
"The token is shown once when it is created and cannot be recovered afterwards."
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
class DeployError extends Error {
|
|
62
|
+
/**
|
|
63
|
+
* @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
|
|
64
|
+
* the client-side codes in {@link ClientErrorCode}.
|
|
65
|
+
* @param message Text safe to show the user.
|
|
66
|
+
* @param detail Anything extra worth logging, such as the RFC 9457 body from the API.
|
|
67
|
+
*/
|
|
68
|
+
constructor(code, message, detail) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.detail = detail;
|
|
72
|
+
this.name = "DeployError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const ClientErrorCode = {
|
|
76
|
+
/** The drop held no files once junk was filtered out. */
|
|
77
|
+
Empty: "empty_drop",
|
|
78
|
+
/** No `index.html` at the root of the drop. */
|
|
79
|
+
MissingIndex: "missing_index",
|
|
80
|
+
/** The drop is larger than the plan allows; the server enforces this too. */
|
|
81
|
+
TooLarge: "too_large",
|
|
82
|
+
/** A single file is larger than the plan allows. */
|
|
83
|
+
FileTooLarge: "file_too_large",
|
|
84
|
+
/** More files than the plan allows. */
|
|
85
|
+
TooManyFiles: "too_many_files",
|
|
86
|
+
/**
|
|
87
|
+
* A zip claims to expand to more than the plan allows.
|
|
88
|
+
*
|
|
89
|
+
* Distinct from {@link TooLarge}, which is about a drop whose files have already been read. This one
|
|
90
|
+
* is raised from the archive's own directory before anything is decompressed, so the numbers in it
|
|
91
|
+
* are what the zip declared rather than what was measured.
|
|
92
|
+
*/
|
|
93
|
+
ZipTooLarge: "zip_too_large",
|
|
94
|
+
/** The user cancelled. */
|
|
95
|
+
Cancelled: "cancelled",
|
|
96
|
+
/** An upload kept failing after every retry. */
|
|
97
|
+
UploadFailed: "upload_failed"
|
|
98
|
+
};
|
|
99
|
+
const CLAIM_TOKEN_HEADER = "X-Claim-Token";
|
|
100
|
+
async function prepareDeploy(siteId, files, options, signal) {
|
|
101
|
+
const manifest = files.map((file) => ({
|
|
102
|
+
path: file.path,
|
|
103
|
+
sha256: file.sha256,
|
|
104
|
+
size: file.bytes.length
|
|
105
|
+
}));
|
|
106
|
+
return request(
|
|
107
|
+
`sites/${encodeURIComponent(siteId)}/deploys/prepare`,
|
|
108
|
+
{ files: manifest },
|
|
109
|
+
options
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
async function completeDeploy(siteId, deployId, options, signal, name) {
|
|
113
|
+
return request(
|
|
114
|
+
`sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
|
|
115
|
+
// A bodyless POST when there is no name to send, which is what this call has always been. The
|
|
116
|
+
// server treats an absent body and an absent field identically, so nothing depends on which of
|
|
117
|
+
// the two a client picks.
|
|
118
|
+
name === null || name === void 0 ? void 0 : { name },
|
|
119
|
+
options
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
async function request(path, body, options, signal) {
|
|
123
|
+
const doFetch = options.fetch ?? globalFetch$1();
|
|
124
|
+
const base = options.baseUrl ?? "/api";
|
|
125
|
+
const headers = {};
|
|
126
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
127
|
+
if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
|
|
128
|
+
if (options.token) headers.Authorization = `Bearer ${options.token}`;
|
|
129
|
+
const response = await doFetch(`${base}/${path}`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers,
|
|
132
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
133
|
+
...{}
|
|
134
|
+
});
|
|
135
|
+
if (!response.ok) throw await problemToError(response);
|
|
136
|
+
return await response.json();
|
|
137
|
+
}
|
|
138
|
+
function globalFetch$1() {
|
|
139
|
+
return globalThis.fetch.bind(globalThis);
|
|
140
|
+
}
|
|
141
|
+
async function problemToError(response) {
|
|
142
|
+
let problem = {};
|
|
143
|
+
try {
|
|
144
|
+
problem = await response.json();
|
|
145
|
+
} catch {
|
|
146
|
+
return new DeployError(
|
|
147
|
+
`http_${response.status}`,
|
|
148
|
+
`The server responded with ${response.status}.`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const code = typeof problem.type === "string" ? problem.type : `http_${response.status}`;
|
|
152
|
+
const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
|
|
153
|
+
return new DeployError(code, message, problem);
|
|
154
|
+
}
|
|
155
|
+
async function sha256Hex(bytes) {
|
|
156
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
|
|
157
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
158
|
+
}
|
|
159
|
+
async function hashAll(files, onProgress, signal) {
|
|
160
|
+
const hashed = [];
|
|
161
|
+
for (const [index, file] of files.entries()) {
|
|
162
|
+
hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
|
|
163
|
+
onProgress?.(index + 1, files.length);
|
|
164
|
+
}
|
|
165
|
+
return hashed;
|
|
166
|
+
}
|
|
167
|
+
const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
|
|
168
|
+
const IGNORED_DIRECTORIES = ["__MACOSX", ".git", "node_modules", ".svn", ".hg", ".idea", ".vscode"];
|
|
169
|
+
function shouldIgnore(path) {
|
|
170
|
+
const segments = path.split("/");
|
|
171
|
+
const name = segments.at(-1) ?? "";
|
|
172
|
+
if (name === "" || IGNORED_EXACT.has(name)) return true;
|
|
173
|
+
if (name.startsWith("._")) return true;
|
|
174
|
+
return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
|
|
175
|
+
}
|
|
176
|
+
const REQUIRED_INDEX = "index.html";
|
|
177
|
+
const DOCUMENT_EXTENSIONS = [".md", ".markdown", ".pdf"];
|
|
178
|
+
function isDocumentPath(path) {
|
|
179
|
+
const lower = path.toLowerCase();
|
|
180
|
+
return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
181
|
+
}
|
|
182
|
+
function formatBytes(bytes) {
|
|
183
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
184
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
185
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
186
|
+
}
|
|
187
|
+
function checkLimits(files, limits) {
|
|
188
|
+
if (files.length === 0) {
|
|
189
|
+
throw new DeployError(
|
|
190
|
+
ClientErrorCode.Empty,
|
|
191
|
+
"That drop contained no files to deploy. Folders like .git and node_modules are skipped."
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const hasIndex = files.some((file) => file.path === REQUIRED_INDEX);
|
|
195
|
+
if (!hasIndex && !files.some((file) => isDocumentPath(file.path))) {
|
|
196
|
+
throw new DeployError(
|
|
197
|
+
ClientErrorCode.MissingIndex,
|
|
198
|
+
`A site needs an ${REQUIRED_INDEX} at its top level, or at least one .md or .pdf file to publish as a documents site. Drop the folder that contains it, not the folder above.`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (!limits) return;
|
|
202
|
+
if (files.length > limits.maxFiles) {
|
|
203
|
+
throw new DeployError(
|
|
204
|
+
ClientErrorCode.TooManyFiles,
|
|
205
|
+
`That is ${files.length} files, and this plan allows ${limits.maxFiles}.`,
|
|
206
|
+
{ limit: limits.maxFiles, actual: files.length }
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const oversized = files.find(
|
|
210
|
+
(file) => file.bytes.length > limits.maxFileBytes
|
|
211
|
+
);
|
|
212
|
+
if (oversized) {
|
|
213
|
+
throw new DeployError(
|
|
214
|
+
ClientErrorCode.FileTooLarge,
|
|
215
|
+
`${oversized.path} is ${formatBytes(oversized.bytes.length)}, and this plan allows ${formatBytes(limits.maxFileBytes)} per file.`,
|
|
216
|
+
{
|
|
217
|
+
path: oversized.path,
|
|
218
|
+
limit: limits.maxFileBytes,
|
|
219
|
+
actual: oversized.bytes.length
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const total = totalBytes(files);
|
|
224
|
+
if (total > limits.maxSiteBytes) {
|
|
225
|
+
throw new DeployError(
|
|
226
|
+
ClientErrorCode.TooLarge,
|
|
227
|
+
`That drop is ${formatBytes(total)}, and this plan allows ${formatBytes(limits.maxSiteBytes)}.`,
|
|
228
|
+
{ limit: limits.maxSiteBytes, actual: total }
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function totalBytes(files) {
|
|
233
|
+
return files.reduce((sum, file) => sum + file.bytes.length, 0);
|
|
234
|
+
}
|
|
235
|
+
const MAX_NAME_LENGTH = 60;
|
|
236
|
+
const HEAD_BYTES = 64 * 1024;
|
|
237
|
+
const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
|
|
238
|
+
function suggestSiteName(files) {
|
|
239
|
+
const index = files.find((file) => file.path === "index.html");
|
|
240
|
+
if (index === void 0) return null;
|
|
241
|
+
try {
|
|
242
|
+
return nameFromHtml(decodeHead(index.bytes));
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function decodeHead(bytes) {
|
|
248
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(
|
|
249
|
+
bytes.subarray(0, HEAD_BYTES)
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
function nameFromHtml(html) {
|
|
253
|
+
const document = parse(html);
|
|
254
|
+
const candidates = document ? [
|
|
255
|
+
document.querySelector('meta[property="og:site_name"]')?.getAttribute("content"),
|
|
256
|
+
document.querySelector("title")?.textContent,
|
|
257
|
+
document.querySelector("h1")?.textContent
|
|
258
|
+
] : [matchOgSiteName(html), matchTag(html, "title"), matchTag(html, "h1")];
|
|
259
|
+
for (const candidate of candidates) {
|
|
260
|
+
const name = clean(candidate);
|
|
261
|
+
if (name !== null) return name;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
function parse(html) {
|
|
266
|
+
const parser = globalThis.DOMParser;
|
|
267
|
+
if (parser === void 0) return null;
|
|
268
|
+
try {
|
|
269
|
+
return new parser().parseFromString(html, "text/html");
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function matchTag(html, tag) {
|
|
275
|
+
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
|
|
276
|
+
html
|
|
277
|
+
);
|
|
278
|
+
return match?.[1] ?? null;
|
|
279
|
+
}
|
|
280
|
+
function matchOgSiteName(html) {
|
|
281
|
+
const patterns = [
|
|
282
|
+
/<meta[^>]*property\s*=\s*["']og:site_name["'][^>]*content\s*=\s*["']([^"']*)["']/i,
|
|
283
|
+
/<meta[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:site_name["']/i
|
|
284
|
+
];
|
|
285
|
+
for (const pattern of patterns) {
|
|
286
|
+
const match = pattern.exec(html);
|
|
287
|
+
if (match?.[1] !== void 0) return match[1];
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
const INVISIBLE = new RegExp(
|
|
292
|
+
`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}${String.fromCharCode(8203)}-${String.fromCharCode(8207)}${String.fromCharCode(8232)}-${String.fromCharCode(8238)}${String.fromCharCode(8288)}-${String.fromCharCode(8292)}${String.fromCharCode(8294)}-${String.fromCharCode(8303)}${String.fromCharCode(65279)}]`,
|
|
293
|
+
"g"
|
|
294
|
+
);
|
|
295
|
+
function clean(raw) {
|
|
296
|
+
if (raw === null || raw === void 0) return null;
|
|
297
|
+
if (raw.includes("<") || raw.includes(">")) return null;
|
|
298
|
+
const collapsed = raw.replace(INVISIBLE, " ").replace(/\s+/g, " ").trim();
|
|
299
|
+
const name = trimSuffix(collapsed);
|
|
300
|
+
if (name.length === 0) return null;
|
|
301
|
+
if (PLACEHOLDERS.has(name.toLowerCase())) return null;
|
|
302
|
+
return name.length > MAX_NAME_LENGTH ? name.slice(0, MAX_NAME_LENGTH).trimEnd() : name;
|
|
303
|
+
}
|
|
304
|
+
const PLACEHOLDERS = /* @__PURE__ */ new Set([
|
|
305
|
+
"document",
|
|
306
|
+
"untitled",
|
|
307
|
+
"untitled document",
|
|
308
|
+
"index",
|
|
309
|
+
"home",
|
|
310
|
+
"new page",
|
|
311
|
+
"my site",
|
|
312
|
+
"vite app",
|
|
313
|
+
"vite + react",
|
|
314
|
+
"react app",
|
|
315
|
+
"create next app",
|
|
316
|
+
"svelte app",
|
|
317
|
+
"webpack app",
|
|
318
|
+
"hello world",
|
|
319
|
+
"title"
|
|
320
|
+
]);
|
|
321
|
+
function trimSuffix(title) {
|
|
322
|
+
for (const separator of TITLE_SEPARATORS) {
|
|
323
|
+
const at = title.lastIndexOf(separator);
|
|
324
|
+
if (at <= 0) continue;
|
|
325
|
+
const before = title.slice(0, at).trim();
|
|
326
|
+
const after = title.slice(at + separator.length).trim();
|
|
327
|
+
if (before.length > 0 && after.length > 0) return after;
|
|
328
|
+
}
|
|
329
|
+
return title;
|
|
330
|
+
}
|
|
331
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
332
|
+
const DEFAULT_RETRIES = 3;
|
|
333
|
+
const BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
334
|
+
async function uploadAll(targets, files, onProgress, signal, deps = {}) {
|
|
335
|
+
const doFetch = deps.fetch ?? globalFetch();
|
|
336
|
+
const delay = deps.delay ?? defaultDelay;
|
|
337
|
+
const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
|
|
338
|
+
const retries = deps.retries ?? DEFAULT_RETRIES;
|
|
339
|
+
const byPath = new Map(files.map((file) => [file.path, file]));
|
|
340
|
+
let nextIndex = 0;
|
|
341
|
+
let done = 0;
|
|
342
|
+
let uploadedBytes = 0;
|
|
343
|
+
const worker = async () => {
|
|
344
|
+
for (; ; ) {
|
|
345
|
+
const index = nextIndex++;
|
|
346
|
+
const target = targets[index];
|
|
347
|
+
if (target === void 0) return;
|
|
348
|
+
const file = byPath.get(target.path);
|
|
349
|
+
if (file === void 0) {
|
|
350
|
+
throw new DeployError(
|
|
351
|
+
ClientErrorCode.UploadFailed,
|
|
352
|
+
`The server asked for ${target.path}, which is not in the manifest.`,
|
|
353
|
+
{ path: target.path }
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
await uploadOne(target, file, { doFetch, delay, retries, signal });
|
|
357
|
+
done += 1;
|
|
358
|
+
uploadedBytes += file.bytes.length;
|
|
359
|
+
onProgress?.(done, targets.length, uploadedBytes);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
const workers = Array.from(
|
|
363
|
+
{ length: Math.min(concurrency, targets.length) },
|
|
364
|
+
worker
|
|
365
|
+
);
|
|
366
|
+
try {
|
|
367
|
+
await Promise.all(workers);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
throw asDeployError(error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function uploadOne(target, file, context) {
|
|
373
|
+
let lastError;
|
|
374
|
+
for (let attempt = 0; attempt <= context.retries; attempt++) {
|
|
375
|
+
context.signal?.throwIfAborted();
|
|
376
|
+
if (attempt > 0) {
|
|
377
|
+
await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
|
|
378
|
+
context.signal?.throwIfAborted();
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const response = await context.doFetch(target.url, {
|
|
382
|
+
method: "PUT",
|
|
383
|
+
body: new Blob([file.bytes.slice().buffer]),
|
|
384
|
+
// Signed into the URL, so this is not optional: storage checks the bytes against it and
|
|
385
|
+
// rejects the PUT if they disagree. `Content-Length` is signed too but cannot be set
|
|
386
|
+
// here — it is a forbidden header name, and the browser derives it from the body, which
|
|
387
|
+
// is exactly the value the server signed.
|
|
388
|
+
headers: { "x-amz-checksum-sha256": toBase64Digest(target.sha256) },
|
|
389
|
+
...context.signal ? { signal: context.signal } : {}
|
|
390
|
+
});
|
|
391
|
+
if (response.ok) return;
|
|
392
|
+
if (response.status === 403 || response.status === 401) {
|
|
393
|
+
throw new DeployError(
|
|
394
|
+
ClientErrorCode.UploadFailed,
|
|
395
|
+
`Upload of ${target.path} was rejected by storage. The upload window may have expired — try deploying again.`,
|
|
396
|
+
{ path: target.path, status: response.status }
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (error instanceof DeployError) throw error;
|
|
402
|
+
if (isAbort(error)) throw cancelled();
|
|
403
|
+
lastError = error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
throw new DeployError(
|
|
407
|
+
ClientErrorCode.UploadFailed,
|
|
408
|
+
`Could not upload ${target.path} after ${context.retries + 1} attempts.`,
|
|
409
|
+
{ path: target.path, cause: String(lastError) }
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
function toBase64Digest(sha256Hex2) {
|
|
413
|
+
const bytes = new Uint8Array(sha256Hex2.length / 2);
|
|
414
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
415
|
+
bytes[i] = Number.parseInt(sha256Hex2.slice(i * 2, i * 2 + 2), 16);
|
|
416
|
+
}
|
|
417
|
+
return btoa(String.fromCharCode(...bytes));
|
|
418
|
+
}
|
|
419
|
+
function globalFetch() {
|
|
420
|
+
return globalThis.fetch.bind(globalThis);
|
|
421
|
+
}
|
|
422
|
+
function defaultDelay(ms) {
|
|
423
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
424
|
+
}
|
|
425
|
+
function isAbort(error) {
|
|
426
|
+
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
427
|
+
}
|
|
428
|
+
function cancelled() {
|
|
429
|
+
return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
|
|
430
|
+
}
|
|
431
|
+
function asDeployError(error) {
|
|
432
|
+
if (error instanceof DeployError) return error;
|
|
433
|
+
if (isAbort(error)) return cancelled();
|
|
434
|
+
return new DeployError(
|
|
435
|
+
ClientErrorCode.UploadFailed,
|
|
436
|
+
error instanceof Error ? error.message : "Upload failed.",
|
|
437
|
+
error
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
441
|
+
try {
|
|
442
|
+
const files = await source(signal);
|
|
443
|
+
onProgress({ type: "collecting", files: files.length });
|
|
444
|
+
const hashed = await hashAll(
|
|
445
|
+
files,
|
|
446
|
+
(done, total) => onProgress({ type: "hashing", done, total }),
|
|
447
|
+
signal
|
|
448
|
+
);
|
|
449
|
+
checkLimits(hashed, options.limits ?? null);
|
|
450
|
+
onProgress({ type: "preparing" });
|
|
451
|
+
const prepared = await prepareDeploy(siteId, hashed, options, signal);
|
|
452
|
+
if (prepared.unchanged) {
|
|
453
|
+
onProgress({ type: "unchanged", url: prepared.url ?? "" });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
await uploadAll(
|
|
457
|
+
prepared.upload,
|
|
458
|
+
hashed,
|
|
459
|
+
(done, _total, bytes) => onProgress({
|
|
460
|
+
type: "uploading",
|
|
461
|
+
done: prepared.reused + done,
|
|
462
|
+
total: prepared.total,
|
|
463
|
+
bytes,
|
|
464
|
+
reused: prepared.reused
|
|
465
|
+
}),
|
|
466
|
+
signal,
|
|
467
|
+
options.upload
|
|
468
|
+
);
|
|
469
|
+
onProgress({ type: "completing", reused: prepared.reused });
|
|
470
|
+
const completed = await completeDeploy(
|
|
471
|
+
siteId,
|
|
472
|
+
prepared.deployId,
|
|
473
|
+
options,
|
|
474
|
+
signal,
|
|
475
|
+
suggestSiteName(files)
|
|
476
|
+
);
|
|
477
|
+
onProgress({ type: "done", url: completed.url, name: completed.name });
|
|
478
|
+
} catch (error) {
|
|
479
|
+
const failure2 = asDeployError(error);
|
|
480
|
+
onProgress({
|
|
481
|
+
type: "error",
|
|
482
|
+
code: failure2.code,
|
|
483
|
+
message: failure2.message,
|
|
484
|
+
...failure2.detail === void 0 ? {} : { detail: failure2.detail }
|
|
485
|
+
});
|
|
486
|
+
throw failure2;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const MAX_FILES = 2e4;
|
|
490
|
+
function directorySource(root) {
|
|
491
|
+
return async () => {
|
|
492
|
+
const base = await realpath(root);
|
|
493
|
+
const files = [];
|
|
494
|
+
await walk(base, base, files);
|
|
495
|
+
return files;
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
async function walk(root, directory, into) {
|
|
499
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
500
|
+
const full = join(directory, entry.name);
|
|
501
|
+
const path = relative(root, full).split(sep).join("/");
|
|
502
|
+
if (shouldIgnore(path)) continue;
|
|
503
|
+
if (entry.isSymbolicLink()) {
|
|
504
|
+
const target = await realpath(full).catch(() => null);
|
|
505
|
+
if (target === null || escapesRoot(root, target)) continue;
|
|
506
|
+
const targeted = await stat(target);
|
|
507
|
+
if (targeted.isDirectory()) {
|
|
508
|
+
await walk(root, target, into);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (!targeted.isFile()) continue;
|
|
512
|
+
} else if (entry.isDirectory()) {
|
|
513
|
+
await walk(root, full, into);
|
|
514
|
+
continue;
|
|
515
|
+
} else if (!entry.isFile()) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (into.length >= MAX_FILES) {
|
|
519
|
+
throw new Error(
|
|
520
|
+
`This folder holds more than ${MAX_FILES.toLocaleString()} files. Publish a build output directory rather than a whole project.`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
into.push({ path, bytes: new Uint8Array(await readFile(full)) });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function escapesRoot(root, candidate) {
|
|
527
|
+
const inside = relative(root, candidate);
|
|
528
|
+
return inside === "" || inside.startsWith("..");
|
|
529
|
+
}
|
|
530
|
+
async function resolveSite(credentials, site) {
|
|
531
|
+
if (site === void 0) return await createSite(credentials);
|
|
532
|
+
const wanted = site.trim().toLowerCase();
|
|
533
|
+
const found = (await listSites(credentials)).find(
|
|
534
|
+
(candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
|
|
535
|
+
);
|
|
536
|
+
if (found === void 0) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`No site of yours is called "${site}". Leave the site out to publish to a new one, or use list_sites to see what exists.`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
return found;
|
|
542
|
+
}
|
|
543
|
+
async function publish(credentials, source, site) {
|
|
544
|
+
const target = await resolveSite(credentials, site);
|
|
545
|
+
let files = 0;
|
|
546
|
+
let url = target.url;
|
|
547
|
+
let unchanged = false;
|
|
548
|
+
let failure2 = null;
|
|
549
|
+
const record = (event) => {
|
|
550
|
+
if (event.type === "collecting") files = event.files;
|
|
551
|
+
if (event.type === "unchanged") {
|
|
552
|
+
unchanged = true;
|
|
553
|
+
url = event.url || target.url;
|
|
554
|
+
}
|
|
555
|
+
if (event.type === "done") url = event.url;
|
|
556
|
+
if (event.type === "error") failure2 = event.message;
|
|
557
|
+
};
|
|
558
|
+
await deploy(source, target.siteId, record, void 0, {
|
|
559
|
+
baseUrl: credentials.apiBaseUrl,
|
|
560
|
+
token: credentials.token
|
|
561
|
+
}).catch((error) => {
|
|
562
|
+
throw new Error(failure2 ?? (error instanceof Error ? error.message : String(error)));
|
|
563
|
+
});
|
|
564
|
+
return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
|
|
565
|
+
}
|
|
566
|
+
function publishDirectory(credentials, directory, site) {
|
|
567
|
+
return publish(credentials, directorySource(directory), site);
|
|
568
|
+
}
|
|
569
|
+
function failure(text) {
|
|
570
|
+
return { text, json: { error: text }, code: 1 };
|
|
571
|
+
}
|
|
572
|
+
function credentialsOr() {
|
|
573
|
+
const credentials = loadCredentials();
|
|
574
|
+
if (credentials === null) return { result: failure(missingCredentialsMessage()) };
|
|
575
|
+
return { credentials };
|
|
576
|
+
}
|
|
577
|
+
async function whoami() {
|
|
578
|
+
const found = credentialsOr();
|
|
579
|
+
if ("result" in found) return found.result;
|
|
580
|
+
const response = await fetch(`${found.credentials.apiBaseUrl}/me`, {
|
|
581
|
+
headers: { Authorization: `Bearer ${found.credentials.token}` }
|
|
582
|
+
});
|
|
583
|
+
if (!response.ok) {
|
|
584
|
+
return failure(
|
|
585
|
+
response.status === 401 ? "This token is not valid any more. It may have been revoked or expired — create another at https://dropto.run/account/tokens." : `The API answered ${response.status}.`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
const me = await response.json();
|
|
589
|
+
const plan = me.plan?.name ? ` on ${me.plan.name}` : "";
|
|
590
|
+
return {
|
|
591
|
+
text: `${me.email}${plan}
|
|
592
|
+
API: ${found.credentials.apiBaseUrl}`,
|
|
593
|
+
json: me,
|
|
594
|
+
code: 0
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async function list() {
|
|
598
|
+
const found = credentialsOr();
|
|
599
|
+
if ("result" in found) return found.result;
|
|
600
|
+
const sites = await listSites(found.credentials);
|
|
601
|
+
if (sites.length === 0) {
|
|
602
|
+
return {
|
|
603
|
+
text: "No sites yet. `drop2run deploy` publishes one.",
|
|
604
|
+
json: { sites: [] },
|
|
605
|
+
code: 0
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
text: sites.map((site) => `${site.subdomain} ${site.url}${site.name ? ` ${site.name}` : ""}`).join("\n"),
|
|
610
|
+
json: { sites },
|
|
611
|
+
code: 0
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
async function deployCommand(directory, site) {
|
|
615
|
+
const found = credentialsOr();
|
|
616
|
+
if ("result" in found) return found.result;
|
|
617
|
+
try {
|
|
618
|
+
const result = await publishDirectory(found.credentials, resolve(directory), site);
|
|
619
|
+
const what = result.unchanged ? "Already up to date — nothing needed publishing." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
|
|
620
|
+
return {
|
|
621
|
+
text: `${what}
|
|
622
|
+
${result.url}`,
|
|
623
|
+
json: result,
|
|
624
|
+
code: 0
|
|
625
|
+
};
|
|
626
|
+
} catch (error) {
|
|
627
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function where() {
|
|
631
|
+
const fromEnvironment = process.env[TOKEN_VARIABLE]?.trim();
|
|
632
|
+
const credentials = loadCredentials();
|
|
633
|
+
const source = fromEnvironment ? `${TOKEN_VARIABLE} (environment)` : credentials === null ? "nowhere — no token found" : configPath();
|
|
634
|
+
return {
|
|
635
|
+
text: [
|
|
636
|
+
`Token source: ${source}`,
|
|
637
|
+
`Config file: ${configPath()}`,
|
|
638
|
+
`API: ${credentials?.apiBaseUrl ?? "—"}`
|
|
639
|
+
].join("\n"),
|
|
640
|
+
json: {
|
|
641
|
+
tokenSource: source,
|
|
642
|
+
configPath: configPath(),
|
|
643
|
+
apiBaseUrl: credentials?.apiBaseUrl ?? null
|
|
644
|
+
},
|
|
645
|
+
code: credentials === null ? 1 : 0
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const HELP = `drop2run — publish a static site from the command line
|
|
649
|
+
|
|
650
|
+
drop2run deploy [dir] [--site <subdomain>] Publish a folder (default: .)
|
|
651
|
+
drop2run ls List your sites
|
|
652
|
+
drop2run whoami Check the token and whose it is
|
|
653
|
+
drop2run where Show which token source is in use
|
|
654
|
+
drop2run --version Print the version
|
|
655
|
+
|
|
656
|
+
Flags
|
|
657
|
+
--json Print machine-readable output instead of text
|
|
658
|
+
--site X Publish over an existing site rather than creating one
|
|
659
|
+
|
|
660
|
+
Signing in
|
|
661
|
+
There is no \`drop2run login\` yet. Create a token at
|
|
662
|
+
https://dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
|
|
663
|
+
environment or put it in ~/.config/drop2run/config.json as {"token": "d2r_..."}.
|
|
664
|
+
`;
|
|
665
|
+
function flagValue(args, flag) {
|
|
666
|
+
const at = args.indexOf(flag);
|
|
667
|
+
if (at === -1) return void 0;
|
|
668
|
+
const value = args[at + 1];
|
|
669
|
+
return value === void 0 || value.startsWith("-") ? void 0 : value;
|
|
670
|
+
}
|
|
671
|
+
async function run(argv) {
|
|
672
|
+
const json = argv.includes("--json");
|
|
673
|
+
const positional = argv.filter((argument) => !argument.startsWith("-"));
|
|
674
|
+
const [command, ...rest] = positional;
|
|
675
|
+
if (argv.includes("--version")) {
|
|
676
|
+
const { version } = await import("./package-VuVin5yv.js").then(
|
|
677
|
+
(module) => module.default
|
|
678
|
+
);
|
|
679
|
+
return { text: version, json: { version }, code: 0 };
|
|
680
|
+
}
|
|
681
|
+
const askedForHelp = argv.includes("--help") || argv.includes("-h") || command === "help";
|
|
682
|
+
if (askedForHelp) return { text: HELP, json: { help: HELP }, code: 0 };
|
|
683
|
+
if (command === void 0) return { text: HELP, json: { help: HELP }, code: 1 };
|
|
684
|
+
const site = flagValue(argv, "--site");
|
|
685
|
+
const result = await dispatch(command, rest, site);
|
|
686
|
+
return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
|
|
687
|
+
}
|
|
688
|
+
async function dispatch(command, rest, site) {
|
|
689
|
+
switch (command) {
|
|
690
|
+
case "deploy":
|
|
691
|
+
return await deployCommand(rest[0] ?? ".", site);
|
|
692
|
+
case "ls":
|
|
693
|
+
case "list":
|
|
694
|
+
return await list();
|
|
695
|
+
case "whoami":
|
|
696
|
+
return await whoami();
|
|
697
|
+
case "where":
|
|
698
|
+
return where();
|
|
699
|
+
default:
|
|
700
|
+
return {
|
|
701
|
+
text: `Unknown command "${command}".
|
|
702
|
+
|
|
703
|
+
${HELP}`,
|
|
704
|
+
json: { error: `Unknown command "${command}".` },
|
|
705
|
+
code: 1
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async function main(argv) {
|
|
710
|
+
const result = await run(argv);
|
|
711
|
+
if (result.code === 0) console.log(result.text);
|
|
712
|
+
else console.error(result.text);
|
|
713
|
+
process.exit(result.code);
|
|
714
|
+
}
|
|
715
|
+
export {
|
|
716
|
+
main
|
|
717
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const name = "drop2run";
|
|
2
|
+
const version = "0.0.0";
|
|
3
|
+
const description = "Publish a static site to Drop2Run from the command line.";
|
|
4
|
+
const license = "MIT";
|
|
5
|
+
const type = "module";
|
|
6
|
+
const bin = { "drop2run": "bin/drop2run.mjs" };
|
|
7
|
+
const files = ["bin", "dist"];
|
|
8
|
+
const engines = { "node": ">=20" };
|
|
9
|
+
const publishConfig = { "access": "public" };
|
|
10
|
+
const homepage = "https://dropto.run";
|
|
11
|
+
const keywords = ["static-site", "deploy", "hosting", "cli"];
|
|
12
|
+
const scripts = { "build": "vite build", "typecheck": "tsc --noEmit", "test": "vitest run", "lint": "biome check .", "format": "biome check --write ." };
|
|
13
|
+
const devDependencies = { "@biomejs/biome": "^2.5.11", "@types/node": "^26.4.0", "typescript": "^5.9.3", "vite": "^7.2.1", "vitest": "^4.1.11" };
|
|
14
|
+
const _package = {
|
|
15
|
+
name,
|
|
16
|
+
version,
|
|
17
|
+
description,
|
|
18
|
+
license,
|
|
19
|
+
type,
|
|
20
|
+
bin,
|
|
21
|
+
files,
|
|
22
|
+
engines,
|
|
23
|
+
publishConfig,
|
|
24
|
+
homepage,
|
|
25
|
+
keywords,
|
|
26
|
+
scripts,
|
|
27
|
+
devDependencies
|
|
28
|
+
};
|
|
29
|
+
export {
|
|
30
|
+
bin,
|
|
31
|
+
_package as default,
|
|
32
|
+
description,
|
|
33
|
+
devDependencies,
|
|
34
|
+
engines,
|
|
35
|
+
files,
|
|
36
|
+
homepage,
|
|
37
|
+
keywords,
|
|
38
|
+
license,
|
|
39
|
+
name,
|
|
40
|
+
publishConfig,
|
|
41
|
+
scripts,
|
|
42
|
+
type,
|
|
43
|
+
version
|
|
44
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "drop2run",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Publish a static site to Drop2Run from the command line.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"drop2run": "bin/drop2run.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://dropto.run",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"static-site",
|
|
23
|
+
"deploy",
|
|
24
|
+
"hosting",
|
|
25
|
+
"cli"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "vite build",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"lint": "biome check .",
|
|
32
|
+
"format": "biome check --write ."
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@biomejs/biome": "^2.5.11",
|
|
36
|
+
"@types/node": "^26.4.0",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"vite": "^7.2.1",
|
|
39
|
+
"vitest": "^4.1.11"
|
|
40
|
+
}
|
|
41
|
+
}
|