drop2run 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -13
- package/dist/index.js +586 -39
- package/dist/{package-VuVin5yv.js → package-BGfKrpb3.js} +2 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,22 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
Publish a static site to [Drop2Run](https://dropto.run) from the command line.
|
|
4
4
|
|
|
5
|
+
```bash
|
|
6
|
+
npm i -g drop2run
|
|
7
|
+
drop2run login
|
|
8
|
+
drop2run deploy dist
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node 20 or newer. That is the whole of it: `login` opens a browser and stores a token, `deploy` prints the
|
|
12
|
+
URL. Full documentation at [dropto.run/docs/cli](https://dropto.run/docs/cli).
|
|
13
|
+
|
|
5
14
|
```
|
|
6
|
-
drop2run
|
|
15
|
+
drop2run login [--device] Sign in and store a token
|
|
16
|
+
drop2run logout Remove the stored token
|
|
17
|
+
drop2run init [dir] [--site <subdomain>] Tie this folder to a site
|
|
18
|
+
drop2run deploy [dir] [--site <subdomain>] Publish a folder
|
|
7
19
|
drop2run ls List your sites
|
|
20
|
+
drop2run open [site] Open a site in a browser
|
|
21
|
+
drop2run rollback <deployId> [--site X] Put an earlier version back live
|
|
22
|
+
drop2run rm <site> --yes Delete a site and everything on it
|
|
23
|
+
drop2run token list List your access tokens
|
|
8
24
|
drop2run whoami Check the token and whose it is
|
|
9
25
|
drop2run where Show which token source is in use
|
|
10
26
|
```
|
|
11
27
|
|
|
12
28
|
`--json` on any command prints machine-readable output instead of text.
|
|
13
29
|
|
|
30
|
+
## Which site a command acts on
|
|
31
|
+
|
|
32
|
+
`--site`, then `drop2run.json`, then a new one. `init` writes that file, so this is the whole of a
|
|
33
|
+
normal project:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
drop2run init dist # creates a site, writes drop2run.json
|
|
37
|
+
drop2run deploy # publishes dist to it, no arguments
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The order is not a convenience. A `deploy` that created a site while a project file sat next to it would
|
|
41
|
+
leave the real site untouched and the person looking at a URL they did not expect.
|
|
42
|
+
|
|
43
|
+
`rm` is the one command that will not act on what it worked out: it prints the site it would delete and
|
|
44
|
+
stops, and only runs with `--yes`. There is no undo — the files go and the subdomain is released — and a
|
|
45
|
+
terminal has no confirmation dialog, so the flag is the dialog.
|
|
46
|
+
|
|
47
|
+
`token create` and `token revoke` do not exist; see below.
|
|
48
|
+
|
|
14
49
|
## Signing in
|
|
15
50
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
51
|
+
`drop2run login` opens a browser, waits on `127.0.0.1`, and stores the token it is handed in
|
|
52
|
+
`~/.config/drop2run/config.json` with mode `0600`.
|
|
53
|
+
|
|
54
|
+
Two things about it are worth knowing, because they decide when it works:
|
|
55
|
+
|
|
56
|
+
- **It needs a browser and loopback on the same machine.** The token is delivered by a redirect to a
|
|
57
|
+
temporary server this process opens on `127.0.0.1`, which is what keeps it out of clipboards and
|
|
58
|
+
scrollback. A remote shell whose browser is on another machine cannot complete that redirect — use
|
|
59
|
+
`--device` there.
|
|
60
|
+
- **It uses PKCE, so there is no client secret.** A verifier is generated per sign-in and never leaves the
|
|
61
|
+
process; only its SHA-256 travels through the browser. A code intercepted anywhere along the way cannot
|
|
62
|
+
be exchanged without the verifier.
|
|
63
|
+
|
|
64
|
+
### `--device`, for a machine with no browser
|
|
65
|
+
|
|
66
|
+
`drop2run login --device` prints a short code and waits. Enter it at <https://dropto.run/device> from any
|
|
67
|
+
machine you are signed in on — a phone will do — and the terminal picks up its token.
|
|
19
68
|
|
|
20
|
-
|
|
69
|
+
The short code is not the credential: it names the pending request, and approving it releases a long code
|
|
70
|
+
that never left the waiting process. Somebody reading it over your shoulder learns which sign-in is
|
|
71
|
+
waiting, not how to collect its token. It lasts fifteen minutes and works once.
|
|
72
|
+
|
|
73
|
+
For CI, neither flow applies — nothing there can open a browser or approve anything. Create a token at
|
|
74
|
+
<https://dropto.run/account/tokens> and either set it in the environment:
|
|
21
75
|
|
|
22
76
|
```
|
|
23
77
|
DROP2RUN_TOKEN=d2r_...
|
|
@@ -44,17 +98,54 @@ one — whoever takes it makes a second, and revoking the first changes nothing
|
|
|
44
98
|
one the owner never made and will not recognise. Both endpoints require a browser session. `gh` and
|
|
45
99
|
`vercel` draw the line in the same place.
|
|
46
100
|
|
|
47
|
-
|
|
101
|
+
`token list` does exist, and answers the question a terminal can answer: which machines are holding a
|
|
102
|
+
credential, and which of them has not used it since it was made. It prints prefixes, never secrets.
|
|
103
|
+
`token revoke` is refused with a message rather than treated as `list` — somebody will type it, and
|
|
104
|
+
listing instead would read as having worked.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
Everything below is for whoever maintains this package. It is here rather than in the monorepo's docs
|
|
109
|
+
because it is about this directory — but it is on the npm page too, so it says who it is for.
|
|
110
|
+
|
|
111
|
+
## Releasing
|
|
112
|
+
|
|
113
|
+
The current version is whatever `package.json` says; the first release with `login` in it was `0.1.0`,
|
|
114
|
+
published 02/09/2026. `0.0.0` is also on the registry and is not a release — it held the bare name
|
|
115
|
+
`drop2run` (brief §9.1) and predates `login` entirely, so nothing should ever be pinned to it.
|
|
116
|
+
|
|
117
|
+
Check a publish from outside rather than from its own output, because the failure worth ruling out is a
|
|
118
|
+
tarball with no `dist` — which the publish log looks perfectly happy about:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
$ npx drop2run@<version> --version
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Publishing is one command:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
cd packages/cli && npm publish
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`prepublishOnly` builds and runs the tests first, and that is load-bearing rather than tidy: `files` is
|
|
131
|
+
`["bin", "dist"]`, `dist` is gitignored, and `bin/drop2run.mjs` imports `../dist/index.js`. A publish from
|
|
132
|
+
a checkout that had not been built would ship a package that throws on its first line — at a version
|
|
133
|
+
number that can never be reused, because npm burns one even after `unpublish`.
|
|
134
|
+
|
|
135
|
+
**No `--provenance`, and not by oversight.** It needs a public repository, and this is a private monorepo;
|
|
136
|
+
the flag fails locally with `Automatic provenance generation not supported for provider: null` and would
|
|
137
|
+
fail in Actions too. Provenance arrives with the `drop2run-cli` split in brief §9.2 — which has its own
|
|
138
|
+
precondition, a `gitleaks` sweep of the whole history rather than of HEAD.
|
|
48
139
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
140
|
+
Two release chores still outstanding, neither blocking: changesets across `core` → `node` → `cli`, whose
|
|
141
|
+
versions have to move in that order, and the fact that this package is the only one of the four with a
|
|
142
|
+
licence field.
|
|
52
143
|
|
|
53
|
-
The bundle itself
|
|
54
|
-
|
|
55
|
-
|
|
144
|
+
The bundle itself works. `@drop2run/core` and `@drop2run/node` are resolved by build aliases rather than
|
|
145
|
+
installed, and `vite build` folds both into `dist/index.js`, so the tarball has no import pointing at
|
|
146
|
+
something npm cannot fetch.
|
|
56
147
|
|
|
57
|
-
## Running it from
|
|
148
|
+
## Running it from the monorepo
|
|
58
149
|
|
|
59
150
|
```bash
|
|
60
151
|
cd packages/cli && npm run build
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { join, relative, sep, resolve } from "node:path";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
1
|
+
import { join, dirname, relative, sep, resolve } from "node:path";
|
|
2
|
+
import { readFileSync, writeFileSync, chmodSync, renameSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
4
|
import { realpath, readdir, stat, readFile } from "node:fs/promises";
|
|
5
|
-
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
async function call(credentials, path, init2 = {}) {
|
|
6
9
|
const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
|
|
7
|
-
...
|
|
10
|
+
...init2,
|
|
8
11
|
headers: {
|
|
9
|
-
...
|
|
12
|
+
...init2.headers,
|
|
10
13
|
Authorization: `Bearer ${credentials.token}`,
|
|
11
|
-
...
|
|
14
|
+
...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
12
15
|
}
|
|
13
16
|
});
|
|
14
17
|
if (!response.ok) {
|
|
@@ -17,6 +20,37 @@ async function call(credentials, path, init = {}) {
|
|
|
17
20
|
}
|
|
18
21
|
return await response.json();
|
|
19
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
|
+
}
|
|
20
54
|
async function listSites(credentials) {
|
|
21
55
|
const body = await call(credentials, "sites");
|
|
22
56
|
return body.sites;
|
|
@@ -47,17 +81,88 @@ function loadCredentials(env = process.env, read = (path) => readFileSync(path,
|
|
|
47
81
|
apiBaseUrl: env.DROP2RUN_API_URL?.trim() || stored.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL
|
|
48
82
|
};
|
|
49
83
|
}
|
|
84
|
+
function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path, "utf8")) {
|
|
85
|
+
let stored = {};
|
|
86
|
+
try {
|
|
87
|
+
stored = JSON.parse(read(configPath()));
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
const url = env.DROP2RUN_API_URL?.trim() || stored.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL;
|
|
91
|
+
return url.replace(/\/+$/, "");
|
|
92
|
+
}
|
|
93
|
+
function dashboardUrlFor(apiBaseUrl) {
|
|
94
|
+
return apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
95
|
+
}
|
|
96
|
+
function saveToken(token, path = configPath()) {
|
|
97
|
+
let existing = {};
|
|
98
|
+
try {
|
|
99
|
+
existing = JSON.parse(readFileSync(path, "utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
103
|
+
const temporary = `${path}.tmp`;
|
|
104
|
+
writeFileSync(temporary, `${JSON.stringify({ ...existing, token }, null, " ")}
|
|
105
|
+
`, {
|
|
106
|
+
mode: 384
|
|
107
|
+
});
|
|
108
|
+
chmodSync(temporary, 384);
|
|
109
|
+
renameSync(temporary, path);
|
|
110
|
+
return path;
|
|
111
|
+
}
|
|
112
|
+
function clearToken(path = configPath()) {
|
|
113
|
+
let existing;
|
|
114
|
+
try {
|
|
115
|
+
existing = JSON.parse(readFileSync(path, "utf8"));
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (existing.token === void 0) return false;
|
|
120
|
+
const { token: _removed, ...rest } = existing;
|
|
121
|
+
const temporary = `${path}.tmp`;
|
|
122
|
+
writeFileSync(temporary, `${JSON.stringify(rest, null, " ")}
|
|
123
|
+
`, { mode: 384 });
|
|
124
|
+
chmodSync(temporary, 384);
|
|
125
|
+
renameSync(temporary, path);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
50
128
|
function missingCredentialsMessage() {
|
|
51
129
|
return [
|
|
52
130
|
"No Drop2Run access token.",
|
|
53
131
|
"",
|
|
54
|
-
"
|
|
132
|
+
"Run `drop2run login` to sign in through a browser, which stores one for you.",
|
|
133
|
+
"",
|
|
134
|
+
"For CI, or where no browser can be opened, create a token at",
|
|
135
|
+
"https://dropto.run/account/tokens and either:",
|
|
55
136
|
` - set ${TOKEN_VARIABLE} in the environment, or`,
|
|
56
137
|
` - put it in ${configPath()} as {"token": "d2r_..."}`,
|
|
57
138
|
"",
|
|
58
139
|
"The token is shown once when it is created and cannot be recovered afterwards."
|
|
59
140
|
].join("\n");
|
|
60
141
|
}
|
|
142
|
+
const PROJECT_FILE = "drop2run.json";
|
|
143
|
+
function projectPath(directory = process.cwd()) {
|
|
144
|
+
return join(directory, PROJECT_FILE);
|
|
145
|
+
}
|
|
146
|
+
function readProject(directory = process.cwd(), read = (path) => readFileSync(path, "utf8")) {
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(read(projectPath(directory)));
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (typeof parsed.siteId !== "string" || parsed.siteId === "") return null;
|
|
154
|
+
return {
|
|
155
|
+
siteId: parsed.siteId,
|
|
156
|
+
subdomain: typeof parsed.subdomain === "string" ? parsed.subdomain : "",
|
|
157
|
+
dir: typeof parsed.dir === "string" && parsed.dir !== "" ? parsed.dir : "."
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function writeProject(project, directory = process.cwd()) {
|
|
161
|
+
const path = projectPath(directory);
|
|
162
|
+
writeFileSync(path, `${JSON.stringify(project, null, " ")}
|
|
163
|
+
`);
|
|
164
|
+
return path;
|
|
165
|
+
}
|
|
61
166
|
class DeployError extends Error {
|
|
62
167
|
/**
|
|
63
168
|
* @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
|
|
@@ -103,11 +208,17 @@ async function prepareDeploy(siteId, files, options, signal) {
|
|
|
103
208
|
sha256: file.sha256,
|
|
104
209
|
size: file.bytes.length
|
|
105
210
|
}));
|
|
106
|
-
|
|
211
|
+
const prepared = await request(
|
|
107
212
|
`sites/${encodeURIComponent(siteId)}/deploys/prepare`,
|
|
108
213
|
{ files: manifest },
|
|
109
214
|
options
|
|
110
215
|
);
|
|
216
|
+
return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
|
|
217
|
+
}
|
|
218
|
+
function absolute(uploadUrl, baseUrl) {
|
|
219
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(uploadUrl)) return uploadUrl;
|
|
220
|
+
if (baseUrl === void 0 || !/^[a-z][a-z0-9+.-]*:/i.test(baseUrl)) return uploadUrl;
|
|
221
|
+
return new URL(uploadUrl, baseUrl).toString();
|
|
111
222
|
}
|
|
112
223
|
async function completeDeploy(siteId, deployId, options, signal, name) {
|
|
113
224
|
return request(
|
|
@@ -165,7 +276,16 @@ async function hashAll(files, onProgress, signal) {
|
|
|
165
276
|
return hashed;
|
|
166
277
|
}
|
|
167
278
|
const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
|
|
168
|
-
const IGNORED_DIRECTORIES = [
|
|
279
|
+
const IGNORED_DIRECTORIES = [
|
|
280
|
+
"__MACOSX",
|
|
281
|
+
".git",
|
|
282
|
+
"node_modules",
|
|
283
|
+
".svn",
|
|
284
|
+
".hg",
|
|
285
|
+
".idea",
|
|
286
|
+
".vscode",
|
|
287
|
+
".claude"
|
|
288
|
+
];
|
|
169
289
|
function shouldIgnore(path) {
|
|
170
290
|
const segments = path.split("/");
|
|
171
291
|
const name = segments.at(-1) ?? "";
|
|
@@ -182,7 +302,8 @@ function isDocumentPath(path) {
|
|
|
182
302
|
function formatBytes(bytes) {
|
|
183
303
|
if (bytes < 1024) return `${bytes} B`;
|
|
184
304
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
185
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
305
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
306
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
186
307
|
}
|
|
187
308
|
function checkLimits(files, limits) {
|
|
188
309
|
if (files.length === 0) {
|
|
@@ -221,7 +342,7 @@ function checkLimits(files, limits) {
|
|
|
221
342
|
);
|
|
222
343
|
}
|
|
223
344
|
const total = totalBytes(files);
|
|
224
|
-
if (total > limits.maxSiteBytes) {
|
|
345
|
+
if (limits.maxSiteBytes !== null && total > limits.maxSiteBytes) {
|
|
225
346
|
throw new DeployError(
|
|
226
347
|
ClientErrorCode.TooLarge,
|
|
227
348
|
`That drop is ${formatBytes(total)}, and this plan allows ${formatBytes(limits.maxSiteBytes)}.`,
|
|
@@ -331,7 +452,7 @@ function trimSuffix(title) {
|
|
|
331
452
|
const DEFAULT_CONCURRENCY = 8;
|
|
332
453
|
const DEFAULT_RETRIES = 3;
|
|
333
454
|
const BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
334
|
-
async function uploadAll(targets, files, onProgress, signal, deps = {}) {
|
|
455
|
+
async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}) {
|
|
335
456
|
const doFetch = deps.fetch ?? globalFetch();
|
|
336
457
|
const delay = deps.delay ?? defaultDelay;
|
|
337
458
|
const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
|
|
@@ -353,7 +474,13 @@ async function uploadAll(targets, files, onProgress, signal, deps = {}) {
|
|
|
353
474
|
{ path: target.path }
|
|
354
475
|
);
|
|
355
476
|
}
|
|
356
|
-
await uploadOne(target, file, {
|
|
477
|
+
await uploadOne(target, file, {
|
|
478
|
+
doFetch,
|
|
479
|
+
delay,
|
|
480
|
+
retries,
|
|
481
|
+
signal,
|
|
482
|
+
uploadUrl
|
|
483
|
+
});
|
|
357
484
|
done += 1;
|
|
358
485
|
uploadedBytes += file.bytes.length;
|
|
359
486
|
onProgress?.(done, targets.length, uploadedBytes);
|
|
@@ -378,21 +505,20 @@ async function uploadOne(target, file, context) {
|
|
|
378
505
|
context.signal?.throwIfAborted();
|
|
379
506
|
}
|
|
380
507
|
try {
|
|
381
|
-
const response = await context.doFetch(
|
|
508
|
+
const response = await context.doFetch(context.uploadUrl, {
|
|
382
509
|
method: "PUT",
|
|
383
510
|
body: new Blob([file.bytes.slice().buffer]),
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
headers: { "x-amz-checksum-sha256": toBase64Digest(target.sha256) },
|
|
511
|
+
// The permit is the whole request. `Content-Length` is required by the Worker and cannot
|
|
512
|
+
// be set here — it is a forbidden header name — but the browser derives it from the body,
|
|
513
|
+
// which is exactly the length the token names.
|
|
514
|
+
headers: { Authorization: `Bearer ${target.token}` },
|
|
389
515
|
...context.signal ? { signal: context.signal } : {}
|
|
390
516
|
});
|
|
391
517
|
if (response.ok) return;
|
|
392
|
-
if (response.status === 403 || response.status ===
|
|
518
|
+
if (response.status === 401 || response.status === 403 || response.status === 429) {
|
|
393
519
|
throw new DeployError(
|
|
394
520
|
ClientErrorCode.UploadFailed,
|
|
395
|
-
`Upload of ${target.path} was
|
|
521
|
+
`Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
|
|
396
522
|
{ path: target.path, status: response.status }
|
|
397
523
|
);
|
|
398
524
|
}
|
|
@@ -409,13 +535,6 @@ async function uploadOne(target, file, context) {
|
|
|
409
535
|
{ path: target.path, cause: String(lastError) }
|
|
410
536
|
);
|
|
411
537
|
}
|
|
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
538
|
function globalFetch() {
|
|
420
539
|
return globalThis.fetch.bind(globalThis);
|
|
421
540
|
}
|
|
@@ -455,6 +574,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
455
574
|
}
|
|
456
575
|
await uploadAll(
|
|
457
576
|
prepared.upload,
|
|
577
|
+
prepared.uploadUrl,
|
|
458
578
|
hashed,
|
|
459
579
|
(done, _total, bytes) => onProgress({
|
|
460
580
|
type: "uploading",
|
|
@@ -546,6 +666,7 @@ async function publish(credentials, source, site) {
|
|
|
546
666
|
let url = target.url;
|
|
547
667
|
let unchanged = false;
|
|
548
668
|
let failure2 = null;
|
|
669
|
+
let failureDetail;
|
|
549
670
|
const record = (event) => {
|
|
550
671
|
if (event.type === "collecting") files = event.files;
|
|
551
672
|
if (event.type === "unchanged") {
|
|
@@ -553,27 +674,281 @@ async function publish(credentials, source, site) {
|
|
|
553
674
|
url = event.url || target.url;
|
|
554
675
|
}
|
|
555
676
|
if (event.type === "done") url = event.url;
|
|
556
|
-
if (event.type === "error")
|
|
677
|
+
if (event.type === "error") {
|
|
678
|
+
failure2 = event.message;
|
|
679
|
+
failureDetail = event.detail;
|
|
680
|
+
}
|
|
557
681
|
};
|
|
558
682
|
await deploy(source, target.siteId, record, void 0, {
|
|
559
683
|
baseUrl: credentials.apiBaseUrl,
|
|
560
684
|
token: credentials.token
|
|
561
685
|
}).catch((error) => {
|
|
562
|
-
|
|
686
|
+
const thrown = new Error(
|
|
687
|
+
failure2 ?? (error instanceof Error ? error.message : String(error))
|
|
688
|
+
);
|
|
689
|
+
thrown.detail = failureDetail ?? error?.detail;
|
|
690
|
+
throw thrown;
|
|
563
691
|
});
|
|
564
692
|
return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
|
|
565
693
|
}
|
|
566
694
|
function publishDirectory(credentials, directory, site) {
|
|
567
695
|
return publish(credentials, directorySource(directory), site);
|
|
568
696
|
}
|
|
697
|
+
const TIMEOUT_MS = 5 * 60 * 1e3;
|
|
698
|
+
function newAttempt() {
|
|
699
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
700
|
+
return {
|
|
701
|
+
verifier,
|
|
702
|
+
challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
703
|
+
state: randomBytes(16).toString("base64url")
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function clientName() {
|
|
707
|
+
const raw = hostname().trim().replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 64);
|
|
708
|
+
return raw === "" ? "drop2run-cli" : raw;
|
|
709
|
+
}
|
|
710
|
+
function openBrowser(url) {
|
|
711
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
712
|
+
try {
|
|
713
|
+
const child = spawn(command, [...args], { stdio: "ignore", detached: true });
|
|
714
|
+
child.unref();
|
|
715
|
+
child.on("error", () => {
|
|
716
|
+
});
|
|
717
|
+
return true;
|
|
718
|
+
} catch {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function listen(state) {
|
|
723
|
+
return new Promise((resolveListener, rejectListener) => {
|
|
724
|
+
let settle = null;
|
|
725
|
+
let fail = null;
|
|
726
|
+
const received = new Promise((resolveCode, rejectCode) => {
|
|
727
|
+
settle = resolveCode;
|
|
728
|
+
fail = rejectCode;
|
|
729
|
+
});
|
|
730
|
+
const server = createServer((request2, response) => {
|
|
731
|
+
const url = new URL(request2.url ?? "/", "http://127.0.0.1");
|
|
732
|
+
if (url.pathname !== "/cb") {
|
|
733
|
+
response.writeHead(404, { "Content-Type": "text/plain" });
|
|
734
|
+
response.end("Not found.\n");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const code = url.searchParams.get("code") ?? "";
|
|
738
|
+
const returned = url.searchParams.get("state") ?? "";
|
|
739
|
+
if (returned !== state || code === "") {
|
|
740
|
+
respond(
|
|
741
|
+
response,
|
|
742
|
+
400,
|
|
743
|
+
"Sign-in could not be completed",
|
|
744
|
+
"This did not come from the sign-in that was started here. Nothing has been changed."
|
|
745
|
+
);
|
|
746
|
+
fail?.(new Error("The browser returned a response that did not match this sign-in."));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
respond(response, 200, "Signed in", "You can close this tab and go back to the terminal.");
|
|
750
|
+
settle?.({ code });
|
|
751
|
+
});
|
|
752
|
+
server.on("error", (error) => {
|
|
753
|
+
rejectListener(error);
|
|
754
|
+
fail?.(error instanceof Error ? error : new Error(String(error)));
|
|
755
|
+
});
|
|
756
|
+
server.listen(0, "127.0.0.1", () => {
|
|
757
|
+
const address = server.address();
|
|
758
|
+
if (address === null) {
|
|
759
|
+
rejectListener(new Error("Could not open a local port to receive the sign-in."));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
resolveListener({
|
|
763
|
+
port: address.port,
|
|
764
|
+
received,
|
|
765
|
+
close: () => server.close()
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
function respond(response, status, title, detail) {
|
|
771
|
+
response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
772
|
+
response.end(
|
|
773
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title} — drop2run</title><style>body{font:16px/1.6 system-ui,sans-serif;margin:20vh auto;max-width:28rem;padding:0 1.5rem;color:#12211f}h1{font-size:1.4rem;margin:0 0 .5rem}p{margin:0;color:#64706e}@media(prefers-color-scheme:dark){body{background:#0d1413;color:#e8efee}p{color:#9aa8a6}}</style></head><body><h1>${title}</h1><p>${detail}</p></body></html>
|
|
774
|
+
`
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
async function waitForCallback(received, timeoutMs = TIMEOUT_MS) {
|
|
778
|
+
let timer;
|
|
779
|
+
try {
|
|
780
|
+
return await Promise.race([
|
|
781
|
+
received,
|
|
782
|
+
new Promise((_, reject) => {
|
|
783
|
+
timer = setTimeout(
|
|
784
|
+
() => reject(new Error("Timed out waiting for the browser. Nothing has been changed.")),
|
|
785
|
+
timeoutMs
|
|
786
|
+
);
|
|
787
|
+
})
|
|
788
|
+
]);
|
|
789
|
+
} finally {
|
|
790
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function consentUrl(dashboardUrl, attempt, port, name) {
|
|
794
|
+
const url = new URL("/cli-auth", dashboardUrl);
|
|
795
|
+
url.searchParams.set("challenge", attempt.challenge);
|
|
796
|
+
url.searchParams.set("state", attempt.state);
|
|
797
|
+
url.searchParams.set("port", String(port));
|
|
798
|
+
url.searchParams.set("name", name);
|
|
799
|
+
return url.toString();
|
|
800
|
+
}
|
|
801
|
+
async function startDevice(apiBaseUrl, clientName2) {
|
|
802
|
+
const response = await fetch(`${apiBaseUrl}/auth/cli/device`, {
|
|
803
|
+
method: "POST",
|
|
804
|
+
headers: { "Content-Type": "application/json" },
|
|
805
|
+
body: JSON.stringify({ clientName: clientName2 })
|
|
806
|
+
});
|
|
807
|
+
if (!response.ok) throw new Error(await problemDetail(response));
|
|
808
|
+
return await response.json();
|
|
809
|
+
}
|
|
810
|
+
async function pollDevice(apiBaseUrl, deviceCode) {
|
|
811
|
+
let response;
|
|
812
|
+
try {
|
|
813
|
+
response = await fetch(`${apiBaseUrl}/auth/cli/device/token`, {
|
|
814
|
+
method: "POST",
|
|
815
|
+
headers: { "Content-Type": "application/json" },
|
|
816
|
+
body: JSON.stringify({ deviceCode })
|
|
817
|
+
});
|
|
818
|
+
} catch {
|
|
819
|
+
return { state: "pending" };
|
|
820
|
+
}
|
|
821
|
+
if (response.ok) {
|
|
822
|
+
const body = await response.json();
|
|
823
|
+
return { state: "granted", token: { token: body.token, name: body.name, email: body.email } };
|
|
824
|
+
}
|
|
825
|
+
const problem = await response.json().catch(() => null);
|
|
826
|
+
if (problem?.type === "authorization_pending") return { state: "pending" };
|
|
827
|
+
if (problem?.type === "slow_down") return { state: "slow_down" };
|
|
828
|
+
return {
|
|
829
|
+
state: "dead",
|
|
830
|
+
message: problem?.detail ?? `The API answered ${response.status}.`
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
async function problemDetail(response) {
|
|
834
|
+
const problem = await response.json().catch(() => null);
|
|
835
|
+
return problem?.detail ?? `The API answered ${response.status}.`;
|
|
836
|
+
}
|
|
837
|
+
async function exchange(apiBaseUrl, code, verifier) {
|
|
838
|
+
const response = await fetch(`${apiBaseUrl}/auth/cli/token`, {
|
|
839
|
+
method: "POST",
|
|
840
|
+
headers: { "Content-Type": "application/json" },
|
|
841
|
+
body: JSON.stringify({ code, verifier })
|
|
842
|
+
});
|
|
843
|
+
if (!response.ok) throw new Error(await problemDetail(response));
|
|
844
|
+
const body = await response.json();
|
|
845
|
+
return { token: body.token, name: body.name, email: body.email };
|
|
846
|
+
}
|
|
569
847
|
function failure(text) {
|
|
570
848
|
return { text, json: { error: text }, code: 1 };
|
|
571
849
|
}
|
|
850
|
+
function failureFrom(error) {
|
|
851
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
852
|
+
const detail = error?.detail;
|
|
853
|
+
const cause = detail?.cause;
|
|
854
|
+
const extra = cause === void 0 || cause === null ? "" : `
|
|
855
|
+
${String(cause)}`;
|
|
856
|
+
return {
|
|
857
|
+
text: `${message}${extra}`,
|
|
858
|
+
json: { error: message, ...detail === void 0 ? {} : { detail } },
|
|
859
|
+
code: 1
|
|
860
|
+
};
|
|
861
|
+
}
|
|
572
862
|
function credentialsOr() {
|
|
573
863
|
const credentials = loadCredentials();
|
|
574
864
|
if (credentials === null) return { result: failure(missingCredentialsMessage()) };
|
|
575
865
|
return { credentials };
|
|
576
866
|
}
|
|
867
|
+
async function login(print = console.error) {
|
|
868
|
+
const apiBaseUrl = resolveApiBaseUrl();
|
|
869
|
+
const attempt = newAttempt();
|
|
870
|
+
const name = clientName();
|
|
871
|
+
let listener;
|
|
872
|
+
try {
|
|
873
|
+
listener = await listen(attempt.state);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
return failure(
|
|
876
|
+
`Could not open a local port to receive the sign-in: ${error instanceof Error ? error.message : String(error)}`
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
const url = consentUrl(dashboardUrlFor(apiBaseUrl), attempt, listener.port, name);
|
|
881
|
+
print(`Opening ${url}`);
|
|
882
|
+
if (!openBrowser(url)) print("Could not open a browser. Open the URL above yourself.");
|
|
883
|
+
print(`Waiting for authorization of "${name}"…`);
|
|
884
|
+
const { code } = await waitForCallback(listener.received);
|
|
885
|
+
const issued = await exchange(apiBaseUrl, code, attempt.verifier);
|
|
886
|
+
const path = saveToken(issued.token);
|
|
887
|
+
return {
|
|
888
|
+
text: `Signed in${issued.email === null ? "" : ` as ${issued.email}`}.
|
|
889
|
+
Token "${issued.name}" saved to ${path}`,
|
|
890
|
+
json: { email: issued.email, name: issued.name, configPath: path, apiBaseUrl },
|
|
891
|
+
code: 0
|
|
892
|
+
};
|
|
893
|
+
} catch (error) {
|
|
894
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
895
|
+
} finally {
|
|
896
|
+
listener.close();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async function loginWithDevice(print = console.error, sleep = (ms) => new Promise((done) => setTimeout(done, ms))) {
|
|
900
|
+
const apiBaseUrl = resolveApiBaseUrl();
|
|
901
|
+
const name = clientName();
|
|
902
|
+
let request2;
|
|
903
|
+
try {
|
|
904
|
+
request2 = await startDevice(apiBaseUrl, name);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
907
|
+
}
|
|
908
|
+
print(`Open ${request2.verificationUri} and enter this code:`);
|
|
909
|
+
print("");
|
|
910
|
+
print(` ${request2.userCode}`);
|
|
911
|
+
print("");
|
|
912
|
+
print(`Or open the link with the code already in it: ${request2.verificationUriComplete}`);
|
|
913
|
+
print(`Waiting for approval of "${name}"…`);
|
|
914
|
+
let waitMs = Math.max(1, request2.intervalSeconds) * 1e3;
|
|
915
|
+
const deadline = Date.now() + DEVICE_TIMEOUT_MS;
|
|
916
|
+
while (Date.now() < deadline) {
|
|
917
|
+
await sleep(waitMs);
|
|
918
|
+
const poll = await pollDevice(apiBaseUrl, request2.deviceCode);
|
|
919
|
+
if (poll.state === "granted") {
|
|
920
|
+
const path = saveToken(poll.token.token);
|
|
921
|
+
return {
|
|
922
|
+
text: `Signed in${poll.token.email === null ? "" : ` as ${poll.token.email}`}.
|
|
923
|
+
Token "${poll.token.name}" saved to ${path}`,
|
|
924
|
+
json: { email: poll.token.email, name: poll.token.name, configPath: path, apiBaseUrl },
|
|
925
|
+
code: 0
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (poll.state === "dead") return failure(poll.message);
|
|
929
|
+
if (poll.state === "slow_down") waitMs = Math.min(waitMs * 2, MAX_POLL_MS);
|
|
930
|
+
}
|
|
931
|
+
return failure("Timed out waiting for approval. Nothing has been changed.");
|
|
932
|
+
}
|
|
933
|
+
const DEVICE_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
934
|
+
const MAX_POLL_MS = 30 * 1e3;
|
|
935
|
+
function logout() {
|
|
936
|
+
const removed = clearToken();
|
|
937
|
+
const fromEnvironment = process.env[TOKEN_VARIABLE]?.trim();
|
|
938
|
+
const lines = [
|
|
939
|
+
removed ? `Removed the token stored in ${configPath()}.` : "There was no stored token to remove."
|
|
940
|
+
];
|
|
941
|
+
if (fromEnvironment) {
|
|
942
|
+
lines.push(
|
|
943
|
+
`${TOKEN_VARIABLE} is still set in this environment, so commands will keep using it. Unset it to sign out fully.`
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
text: lines.join("\n"),
|
|
948
|
+
json: { removed, environmentTokenStillSet: Boolean(fromEnvironment) },
|
|
949
|
+
code: 0
|
|
950
|
+
};
|
|
951
|
+
}
|
|
577
952
|
async function whoami() {
|
|
578
953
|
const found = credentialsOr();
|
|
579
954
|
if ("result" in found) return found.result;
|
|
@@ -614,8 +989,11 @@ async function list() {
|
|
|
614
989
|
async function deployCommand(directory, site) {
|
|
615
990
|
const found = credentialsOr();
|
|
616
991
|
if ("result" in found) return found.result;
|
|
992
|
+
const project = readProject();
|
|
993
|
+
const target = site ?? project?.siteId;
|
|
994
|
+
const folder = directory ?? project?.dir ?? ".";
|
|
617
995
|
try {
|
|
618
|
-
const result = await publishDirectory(found.credentials, resolve(
|
|
996
|
+
const result = await publishDirectory(found.credentials, resolve(folder), target);
|
|
619
997
|
const what = result.unchanged ? "Already up to date — nothing needed publishing." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
|
|
620
998
|
return {
|
|
621
999
|
text: `${what}
|
|
@@ -623,10 +1001,133 @@ ${result.url}`,
|
|
|
623
1001
|
json: result,
|
|
624
1002
|
code: 0
|
|
625
1003
|
};
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
return failureFrom(error);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
async function init(directory, site) {
|
|
1009
|
+
const found = credentialsOr();
|
|
1010
|
+
if ("result" in found) return found.result;
|
|
1011
|
+
if (readProject() !== null) {
|
|
1012
|
+
return failure(
|
|
1013
|
+
`This folder already has a ${PROJECT_FILE}. Edit it, or delete it and run \`init\` again.`
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
try {
|
|
1017
|
+
const target = site === void 0 ? await createSite(found.credentials) : await findSite(found.credentials, site);
|
|
1018
|
+
const project = {
|
|
1019
|
+
siteId: target.siteId,
|
|
1020
|
+
subdomain: target.subdomain,
|
|
1021
|
+
dir: directory
|
|
1022
|
+
};
|
|
1023
|
+
const path = writeProject(project);
|
|
1024
|
+
return {
|
|
1025
|
+
text: `Wrote ${path}
|
|
1026
|
+
${target.url}
|
|
1027
|
+
\`drop2run deploy\` now publishes ${directory} to this site.`,
|
|
1028
|
+
json: { ...project, url: target.url, configPath: path },
|
|
1029
|
+
code: 0
|
|
1030
|
+
};
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
async function remove(site, confirmed) {
|
|
1036
|
+
const found = credentialsOr();
|
|
1037
|
+
if ("result" in found) return found.result;
|
|
1038
|
+
const named = site ?? readProject()?.siteId;
|
|
1039
|
+
if (named === void 0) {
|
|
1040
|
+
return failure(
|
|
1041
|
+
`Name the site to delete: \`drop2run rm <subdomain>\`, or run this in a folder with a ${PROJECT_FILE}.`
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
const target = await findSite(found.credentials, named);
|
|
1046
|
+
if (!confirmed) {
|
|
1047
|
+
return failure(
|
|
1048
|
+
`This would delete ${target.subdomain} and every version published to it, with no way back.
|
|
1049
|
+
Run \`drop2run rm ${target.subdomain} --yes\` if that is what you want.`
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
await deleteSite(found.credentials, target.siteId);
|
|
1053
|
+
return {
|
|
1054
|
+
text: `Deleted ${target.subdomain}.`,
|
|
1055
|
+
json: { siteId: target.siteId, subdomain: target.subdomain, deleted: true },
|
|
1056
|
+
code: 0
|
|
1057
|
+
};
|
|
626
1058
|
} catch (error) {
|
|
627
1059
|
return failure(error instanceof Error ? error.message : String(error));
|
|
628
1060
|
}
|
|
629
1061
|
}
|
|
1062
|
+
async function rollback(deployId, site) {
|
|
1063
|
+
const found = credentialsOr();
|
|
1064
|
+
if ("result" in found) return found.result;
|
|
1065
|
+
if (deployId === void 0) {
|
|
1066
|
+
return failure("Name the version to roll back to: `drop2run rollback <deployId>`.");
|
|
1067
|
+
}
|
|
1068
|
+
const named = site ?? readProject()?.siteId;
|
|
1069
|
+
if (named === void 0) {
|
|
1070
|
+
return failure(
|
|
1071
|
+
`Name the site: \`drop2run rollback <deployId> --site <subdomain>\`, or run this in a folder with a ${PROJECT_FILE}.`
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
const target = await findSite(found.credentials, named);
|
|
1076
|
+
const promoted = await promoteDeploy(found.credentials, target.siteId, deployId);
|
|
1077
|
+
return {
|
|
1078
|
+
text: `${target.subdomain} is back on ${promoted.deployId}.
|
|
1079
|
+
${promoted.url}`,
|
|
1080
|
+
json: promoted,
|
|
1081
|
+
code: 0
|
|
1082
|
+
};
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
async function open(site, launch = openBrowser) {
|
|
1088
|
+
const found = credentialsOr();
|
|
1089
|
+
if ("result" in found) return found.result;
|
|
1090
|
+
const named = site ?? readProject()?.siteId;
|
|
1091
|
+
if (named === void 0) {
|
|
1092
|
+
return failure(
|
|
1093
|
+
`Name the site: \`drop2run open <subdomain>\`, or run this in a folder with a ${PROJECT_FILE}.`
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
const target = await findSite(found.credentials, named);
|
|
1098
|
+
const opened = launch(target.url);
|
|
1099
|
+
return {
|
|
1100
|
+
text: opened ? target.url : `${target.url}
|
|
1101
|
+
(Could not open a browser — open it yourself.)`,
|
|
1102
|
+
json: { url: target.url, siteId: target.siteId, subdomain: target.subdomain, opened },
|
|
1103
|
+
code: 0
|
|
1104
|
+
};
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
async function tokens() {
|
|
1110
|
+
const found = credentialsOr();
|
|
1111
|
+
if ("result" in found) return found.result;
|
|
1112
|
+
try {
|
|
1113
|
+
const list2 = await listTokens(found.credentials);
|
|
1114
|
+
if (list2.length === 0) {
|
|
1115
|
+
return { text: "No access tokens.", json: { tokens: [] }, code: 0 };
|
|
1116
|
+
}
|
|
1117
|
+
const text = list2.map((token) => {
|
|
1118
|
+
const state = token.revokedAt !== null ? "revoked" : token.expiresAt !== null && new Date(token.expiresAt).getTime() <= Date.now() ? "expired" : "active";
|
|
1119
|
+
const used = token.lastUsedAt === null ? "never used" : `last used ${day(token.lastUsedAt)}`;
|
|
1120
|
+
return `${token.prefix}… ${token.name} ${state} ${used}`;
|
|
1121
|
+
}).join("\n");
|
|
1122
|
+
return { text, json: { tokens: list2 }, code: 0 };
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
function day(iso) {
|
|
1128
|
+
const parsed = new Date(iso);
|
|
1129
|
+
return Number.isNaN(parsed.getTime()) ? iso : parsed.toISOString().slice(0, 10);
|
|
1130
|
+
}
|
|
630
1131
|
function where() {
|
|
631
1132
|
const fromEnvironment = process.env[TOKEN_VARIABLE]?.trim();
|
|
632
1133
|
const credentials = loadCredentials();
|
|
@@ -647,18 +1148,35 @@ function where() {
|
|
|
647
1148
|
}
|
|
648
1149
|
const HELP = `drop2run — publish a static site from the command line
|
|
649
1150
|
|
|
650
|
-
drop2run
|
|
1151
|
+
drop2run login [--device] Sign in and store a token
|
|
1152
|
+
drop2run logout Remove the stored token
|
|
1153
|
+
drop2run init [dir] [--site <subdomain>] Tie this folder to a site (writes drop2run.json)
|
|
1154
|
+
drop2run deploy [dir] [--site <subdomain>] Publish a folder (default: . or drop2run.json)
|
|
651
1155
|
drop2run ls List your sites
|
|
1156
|
+
drop2run open [site] Open a site in a browser
|
|
1157
|
+
drop2run rollback <deployId> [--site X] Put an earlier version back live
|
|
1158
|
+
drop2run rm <site> --yes Delete a site and everything on it
|
|
1159
|
+
drop2run token list List your access tokens
|
|
652
1160
|
drop2run whoami Check the token and whose it is
|
|
653
1161
|
drop2run where Show which token source is in use
|
|
654
1162
|
drop2run --version Print the version
|
|
655
1163
|
|
|
656
1164
|
Flags
|
|
657
1165
|
--json Print machine-readable output instead of text
|
|
658
|
-
--site X
|
|
1166
|
+
--site X Act on an existing site rather than the one in drop2run.json
|
|
1167
|
+
--device Sign in by approving a code on another machine
|
|
1168
|
+
--yes Confirm a deletion, which cannot be undone
|
|
1169
|
+
|
|
1170
|
+
Where a site comes from
|
|
1171
|
+
--site, then drop2run.json, then a new one. \`init\` writes that file, so
|
|
1172
|
+
\`deploy\` in the same folder needs no arguments at all.
|
|
659
1173
|
|
|
660
1174
|
Signing in
|
|
661
|
-
|
|
1175
|
+
\`login\` opens a browser and listens on 127.0.0.1, so it needs both on this
|
|
1176
|
+
machine. Over SSH, in a container or under WSL, use \`login --device\`: it
|
|
1177
|
+
prints a short code to enter at https://dropto.run/device from anywhere.
|
|
1178
|
+
|
|
1179
|
+
For CI, neither flow applies — create a token at
|
|
662
1180
|
https://dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
|
|
663
1181
|
environment or put it in ~/.config/drop2run/config.json as {"token": "d2r_..."}.
|
|
664
1182
|
`;
|
|
@@ -673,7 +1191,7 @@ async function run(argv) {
|
|
|
673
1191
|
const positional = argv.filter((argument) => !argument.startsWith("-"));
|
|
674
1192
|
const [command, ...rest] = positional;
|
|
675
1193
|
if (argv.includes("--version")) {
|
|
676
|
-
const { version } = await import("./package-
|
|
1194
|
+
const { version } = await import("./package-BGfKrpb3.js").then(
|
|
677
1195
|
(module) => module.default
|
|
678
1196
|
);
|
|
679
1197
|
return { text: version, json: { version }, code: 0 };
|
|
@@ -682,16 +1200,45 @@ async function run(argv) {
|
|
|
682
1200
|
if (askedForHelp) return { text: HELP, json: { help: HELP }, code: 0 };
|
|
683
1201
|
if (command === void 0) return { text: HELP, json: { help: HELP }, code: 1 };
|
|
684
1202
|
const site = flagValue(argv, "--site");
|
|
685
|
-
const result = await dispatch(
|
|
1203
|
+
const result = await dispatch(
|
|
1204
|
+
command,
|
|
1205
|
+
rest,
|
|
1206
|
+
site,
|
|
1207
|
+
json,
|
|
1208
|
+
argv.includes("--device"),
|
|
1209
|
+
argv.includes("--yes")
|
|
1210
|
+
);
|
|
686
1211
|
return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
|
|
687
1212
|
}
|
|
688
|
-
async function dispatch(command, rest, site) {
|
|
1213
|
+
async function dispatch(command, rest, site, json, device, confirmed) {
|
|
689
1214
|
switch (command) {
|
|
1215
|
+
case "login": {
|
|
1216
|
+
const print = json ? () => {
|
|
1217
|
+
} : (line) => console.error(line);
|
|
1218
|
+
return device ? await loginWithDevice(print) : await login(print);
|
|
1219
|
+
}
|
|
1220
|
+
case "logout":
|
|
1221
|
+
return logout();
|
|
1222
|
+
case "init":
|
|
1223
|
+
return await init(rest[0] ?? ".", site);
|
|
690
1224
|
case "deploy":
|
|
691
|
-
return await deployCommand(rest[0]
|
|
1225
|
+
return await deployCommand(rest[0], site);
|
|
692
1226
|
case "ls":
|
|
693
1227
|
case "list":
|
|
694
1228
|
return await list();
|
|
1229
|
+
case "open":
|
|
1230
|
+
return await open(rest[0] ?? site);
|
|
1231
|
+
case "rollback":
|
|
1232
|
+
return await rollback(rest[0], site);
|
|
1233
|
+
case "rm":
|
|
1234
|
+
return await remove(rest[0] ?? site, confirmed);
|
|
1235
|
+
case "token":
|
|
1236
|
+
case "tokens":
|
|
1237
|
+
return rest[0] === void 0 || rest[0] === "list" ? await tokens() : {
|
|
1238
|
+
text: `\`token ${rest[0]}\` does not exist. Only \`token list\` does: creating and revoking a token need a browser session, at https://dropto.run/account/tokens.`,
|
|
1239
|
+
json: { error: `Unknown token subcommand "${rest[0]}".` },
|
|
1240
|
+
code: 1
|
|
1241
|
+
};
|
|
695
1242
|
case "whoami":
|
|
696
1243
|
return await whoami();
|
|
697
1244
|
case "where":
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const name = "drop2run";
|
|
2
|
-
const version = "0.
|
|
2
|
+
const version = "0.1.1";
|
|
3
3
|
const description = "Publish a static site to Drop2Run from the command line.";
|
|
4
4
|
const license = "MIT";
|
|
5
5
|
const type = "module";
|
|
@@ -9,7 +9,7 @@ const engines = { "node": ">=20" };
|
|
|
9
9
|
const publishConfig = { "access": "public" };
|
|
10
10
|
const homepage = "https://dropto.run";
|
|
11
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 ." };
|
|
12
|
+
const scripts = { "prepublishOnly": "npm run build && npm test", "build": "vite build", "typecheck": "tsc --noEmit", "test": "vitest run", "lint": "biome check .", "format": "biome check --write ." };
|
|
13
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
14
|
const _package = {
|
|
15
15
|
name,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drop2run",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Publish a static site to Drop2Run from the command line.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"cli"
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
|
+
"prepublishOnly": "npm run build && npm test",
|
|
28
29
|
"build": "vite build",
|
|
29
30
|
"typecheck": "tsc --noEmit",
|
|
30
31
|
"test": "vitest run",
|