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 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 deploy [dir] [--site <subdomain>] Publish a folder (default: .)
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
- **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.
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
- Until then, create a token at <https://dropto.run/account/tokens> and either set it in the environment:
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
- ## Not published yet
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
- `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.
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 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.
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 a checkout
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
- async function call(credentials, path, init = {}) {
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
- ...init,
10
+ ...init2,
8
11
  headers: {
9
- ...init.headers,
12
+ ...init2.headers,
10
13
  Authorization: `Bearer ${credentials.token}`,
11
- ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
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
- "Create one at https://dropto.run/account/tokens, then either:",
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
- return request(
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 = ["__MACOSX", ".git", "node_modules", ".svn", ".hg", ".idea", ".vscode"];
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, { doFetch, delay, retries, signal });
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(target.url, {
508
+ const response = await context.doFetch(context.uploadUrl, {
382
509
  method: "PUT",
383
510
  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) },
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 === 401) {
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 rejected by storage. The upload window may have expired — try deploying again.`,
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") failure2 = event.message;
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
- throw new Error(failure2 ?? (error instanceof Error ? error.message : String(error)));
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(directory), site);
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 deploy [dir] [--site <subdomain>] Publish a folder (default: .)
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 Publish over an existing site rather than creating one
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
- There is no \`drop2run login\` yet. Create a token at
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-VuVin5yv.js").then(
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(command, rest, site);
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] ?? ".", site);
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.0.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.0.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",