@dropthis/cli 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,18 +88,19 @@ dropthis ./dist --url
88
88
  dropthis publish ./dist \
89
89
  --title "Launch page" \
90
90
  --visibility unlisted \
91
- --password s3cret \
92
91
  --entry index.html \
93
92
  --expires-at 2026-12-31T00:00:00Z
94
93
  ```
95
94
 
95
+ `publish` always creates a NEW drop with a new URL. To change a drop you already published, use `update-content` or `update-settings` with its `drop_…` id — never publish again.
96
+
96
97
  All publish flags:
97
98
 
98
99
  | Flag | Description |
99
100
  |------|-------------|
100
101
  | `--title <title>` | Drop title |
101
102
  | `--visibility <public\|unlisted>` | Drop visibility |
102
- | `--password <password>` | Set password protection |
103
+ | `--password <password>` | Set password protection (ships with Pro; not enabled on any tier yet) |
103
104
  | `--noindex` | Prevent search indexing |
104
105
  | `--entry <path>` | Entry file for directories |
105
106
  | `--content-type <mime>` | MIME type (required for stdin) |
@@ -141,6 +142,33 @@ dropthis update-content drop_abc123 ./dist-v2 --if-revision 1
141
142
 
142
143
  `update-content` replaces the files at the URL; `update-settings` changes title, visibility, password, noindex, expiry, or metadata. Creating a new drop is always `publish` — neither update command makes a new URL.
143
144
 
145
+ With `--if-revision`, a concurrent edit fails with a 409 instead of clobbering. The error shows the drop's `current_revision` and how to retry:
146
+
147
+ ```text
148
+ ✗ Revision mismatch
149
+ The drop changed since it was last read. Re-read it with GET /v1/drops/{drop_id} and retry with if_revision: 2 (sent as the If-Revision header).
150
+ Current revision: 2 — retry with --if-revision 2
151
+ ```
152
+
153
+ In JSON mode the same error carries `current_revision` and a `next_action` field.
154
+
155
+ ## Pull (read back)
156
+
157
+ Download what a drop is serving — by `drop_…` id, drop URL, or slug. URLs and slugs are resolved to your own drops; the local copy is also the rollback path (pull an old state, then `update-content` it back):
158
+
159
+ ```bash
160
+ # By id, into ./drop_abc123/
161
+ dropthis pull drop_abc123
162
+
163
+ # By URL — resolves the slug to your drop, writes into ./abc123/
164
+ dropthis pull https://abc123.dropthis.app
165
+
166
+ # Choose the output directory
167
+ dropthis pull drop_abc123 -o ./site
168
+ ```
169
+
170
+ Pull fetches the current deployment's file manifest and writes every file into the output directory. It is owner-side read-back via the API — it works regardless of any viewer password. Custom-domain URLs are not resolvable yet; use the `drop_…` id instead.
171
+
144
172
  ## Commands
145
173
 
146
174
  ```bash
@@ -149,6 +177,7 @@ dropthis publish [input...] # Same as above, explicit form
149
177
 
150
178
  dropthis update-content <drop-id> [input] # Replace a drop's content (same URL)
151
179
  dropthis update-settings <drop-id> # Change title/visibility/password/expiry/metadata
180
+ dropthis pull <id|url> [-o <dir>] # Download a drop's files to a local directory
152
181
  dropthis get <drop-id> # Show drop details
153
182
  dropthis list # List your drops
154
183
  dropthis delete <drop-id> # Delete a drop (--yes to confirm)
@@ -233,6 +262,14 @@ Reports CLI version, auth source (`env`, `flag`, `storage`, or `missing`), and c
233
262
  {"ok":true,"version":"0.4.1","auth":{"source":"env"},"storage":{"backend":"secure"}}
234
263
  ```
235
264
 
265
+ ## Pricing tiers
266
+
267
+ - **Free ($0)** — drops expire after 7 days, 5 MB per drop, dropthis badge.
268
+ - **Personal ($5/mo)** — drops stay live while subscribed (no expiry), no badge, 100 MB per drop, 2 GB account storage.
269
+ - **Pro ($19/mo)** — everything in Personal plus password protection, custom domains, and analytics.
270
+
271
+ `dropthis account` shows your active tier and its limits. Note: password protection ships with Pro and is not enabled on any tier yet — the API rejects the `--password` option until then.
272
+
236
273
  ## Agent skills
237
274
 
238
275
  For AI coding agents (Cursor, Claude Code, Windsurf, etc.), install the [dropthis-skills](https://github.com/dropthis-dev/dropthis-skills) package:
package/dist/cli.cjs CHANGED
@@ -135,10 +135,10 @@ function readlineQuestion(question) {
135
135
  input: process.stdin,
136
136
  output: process.stderr
137
137
  });
138
- return new Promise((resolve) => {
138
+ return new Promise((resolve2) => {
139
139
  rl.question(question, (answer) => {
140
140
  rl.close();
141
- resolve(answer);
141
+ resolve2(answer);
142
142
  });
143
143
  });
144
144
  }
@@ -207,7 +207,7 @@ function nextActionForApiError(error2) {
207
207
  const uploadNextAction = error2.code ? UPLOAD_NEXT_ACTIONS[error2.code] : void 0;
208
208
  if (uploadNextAction) return uploadNextAction;
209
209
  if (error2.code === "revision_conflict") {
210
- return "Fetch the drop, merge your changes, and retry with the current revision.";
210
+ return "Re-read the drop with dropthis get <drop-id>, merge your changes, and retry with --if-revision set to the current revision.";
211
211
  }
212
212
  if (error2.statusCode === 404 || error2.code === "not_found") {
213
213
  return "Check the drop id or slug and retry; list your drops with dropthis list.";
@@ -371,6 +371,11 @@ function writeApiError(deps, details) {
371
371
  const nextAction = nextActionForApiError(details);
372
372
  if (nextAction) deps.stderr(`${hint(nextAction)}
373
373
  `);
374
+ if (details.currentRevision !== void 0)
375
+ deps.stderr(
376
+ `${hint(`Current revision: ${details.currentRevision} \u2014 retry with --if-revision ${details.currentRevision}`)}
377
+ `
378
+ );
374
379
  if (details.requestId)
375
380
  deps.stderr(`${import_picocolors2.default.dim(`Request ID: ${details.requestId}`)}
376
381
  `);
@@ -605,6 +610,13 @@ var COMMAND_ANNOTATIONS = {
605
610
  auth: "required",
606
611
  examples: ["dropthis update-settings drop_abc --title 'Launch' --json"]
607
612
  },
613
+ pull: {
614
+ auth: "required",
615
+ examples: [
616
+ "dropthis pull drop_abc -o ./site",
617
+ "dropthis pull https://abc123.dropthis.app"
618
+ ]
619
+ },
608
620
  get: {
609
621
  auth: "required",
610
622
  examples: ["dropthis get drop_abc --json"]
@@ -769,13 +781,13 @@ async function runDoctor(_input, deps) {
769
781
  const authSource = credential?.source ?? "missing";
770
782
  const storageBackend = stored?.storage ?? "none";
771
783
  const pairs = [
772
- ["Version", "0.9.3"],
784
+ ["Version", "0.10.0"],
773
785
  ["Auth", authSource],
774
786
  ["Storage", storageBackend]
775
787
  ];
776
788
  writeKv(deps, pairs, {
777
789
  ok: true,
778
- version: "0.9.3",
790
+ version: "0.10.0",
779
791
  auth: { source: authSource },
780
792
  storage: { backend: storageBackend }
781
793
  });
@@ -1274,6 +1286,110 @@ async function handlePublishDryRun(input, raw, deps, client) {
1274
1286
  }
1275
1287
  }
1276
1288
 
1289
+ // src/commands/pull.ts
1290
+ var import_promises2 = require("fs/promises");
1291
+ var import_node_path = require("path");
1292
+ async function runPull(target, input, deps) {
1293
+ try {
1294
+ await requireCredential({
1295
+ ...deps.apiKey ? { apiKey: deps.apiKey } : {},
1296
+ env: deps.env,
1297
+ store: deps.store
1298
+ });
1299
+ } catch {
1300
+ return writeAuthError(deps);
1301
+ }
1302
+ let dropId;
1303
+ let defaultDirName;
1304
+ if (target.startsWith("drop_")) {
1305
+ dropId = target;
1306
+ defaultDirName = target;
1307
+ } else {
1308
+ const resolved = await deps.client.drops.resolve(target);
1309
+ if (resolved.error) return writeApiError(deps, resolved.error);
1310
+ if (resolved.data === null) {
1311
+ return writeApiError(deps, {
1312
+ code: "not_found",
1313
+ message: `No drop matching "${target}" found in your account.`,
1314
+ suggestion: "It may belong to another account or have been deleted. Run dropthis list to see your drops, or pass the full drop_\u2026 id."
1315
+ });
1316
+ }
1317
+ dropId = resolved.data.id;
1318
+ defaultDirName = resolved.data.slug || resolved.data.id;
1319
+ }
1320
+ const spin = shouldSpin(deps.outputMode) ? createSpinner("Pulling content\u2026", deps.stderr) : void 0;
1321
+ const manifestResult = await deps.client.drops.getContent(dropId);
1322
+ if (manifestResult.error) {
1323
+ spin?.fail();
1324
+ return writeApiError(deps, manifestResult.error);
1325
+ }
1326
+ const manifest = manifestResult.data;
1327
+ const outDir = (0, import_node_path.resolve)(
1328
+ deps.cwd ?? process.cwd(),
1329
+ input.output ?? defaultDirName
1330
+ );
1331
+ let destinations;
1332
+ try {
1333
+ destinations = manifest.files.map((file) => ({
1334
+ path: file.path,
1335
+ dest: safeDestination(outDir, file.path)
1336
+ }));
1337
+ } catch (error2) {
1338
+ spin?.fail();
1339
+ const message = error2 instanceof Error ? error2.message : "Invalid manifest path.";
1340
+ return writeError(deps, "local_input_error", message);
1341
+ }
1342
+ try {
1343
+ await (0, import_promises2.mkdir)(outDir, { recursive: true });
1344
+ for (const { path, dest } of destinations) {
1345
+ const fileResult = await deps.client.drops.getContent(dropId, { path });
1346
+ if (fileResult.error) {
1347
+ spin?.fail();
1348
+ return writeApiError(deps, {
1349
+ ...fileResult.error,
1350
+ message: `${path}: ${fileResult.error.message}`
1351
+ });
1352
+ }
1353
+ const file = fileResult.data;
1354
+ await (0, import_promises2.mkdir)((0, import_node_path.dirname)(dest), { recursive: true });
1355
+ await (0, import_promises2.writeFile)(dest, file.bytes);
1356
+ }
1357
+ } catch (error2) {
1358
+ spin?.fail();
1359
+ const message = error2 instanceof Error ? error2.message : "Failed to write files.";
1360
+ return writeError(deps, "local_input_error", message);
1361
+ }
1362
+ spin?.stop();
1363
+ const count = manifest.files.length;
1364
+ writeHumanOrJson(
1365
+ deps,
1366
+ success(`Pulled ${count} ${count === 1 ? "file" : "files"} to ${outDir}`),
1367
+ {
1368
+ ok: true,
1369
+ pulled: {
1370
+ dropId: manifest.dropId,
1371
+ deploymentId: manifest.deploymentId,
1372
+ revision: manifest.revision,
1373
+ ...manifest.entry !== void 0 ? { entry: manifest.entry } : {},
1374
+ dir: outDir,
1375
+ files: manifest.files
1376
+ }
1377
+ }
1378
+ );
1379
+ return { exitCode: 0 };
1380
+ }
1381
+ function safeDestination(outDir, filePath) {
1382
+ if (!filePath || (0, import_node_path.isAbsolute)(filePath)) {
1383
+ throw new Error(`Unsafe file path in content manifest: "${filePath}"`);
1384
+ }
1385
+ const dest = (0, import_node_path.join)(outDir, filePath);
1386
+ const rel = (0, import_node_path.relative)(outDir, dest);
1387
+ if (rel.startsWith("..") || (0, import_node_path.isAbsolute)(rel)) {
1388
+ throw new Error(`Unsafe file path in content manifest: "${filePath}"`);
1389
+ }
1390
+ return dest;
1391
+ }
1392
+
1277
1393
  // src/commands/update-content.ts
1278
1394
  var NO_CONTENT_MESSAGE = "update-content requires content. Provide a file, folder, URL, or text to ship a new deployment.";
1279
1395
  var NO_CONTENT_NEXT_ACTION = "Pass an input, e.g. dropthis update-content <dropId> ./dist. To change title, visibility, password, expiry, or metadata, use dropthis update-settings <dropId> instead.";
@@ -1691,9 +1807,9 @@ function createContext(input) {
1691
1807
  }
1692
1808
 
1693
1809
  // src/storage.ts
1694
- var import_promises2 = require("fs/promises");
1810
+ var import_promises3 = require("fs/promises");
1695
1811
  var import_node_os = require("os");
1696
- var import_node_path = require("path");
1812
+ var import_node_path2 = require("path");
1697
1813
  var import_keyring = require("@napi-rs/keyring");
1698
1814
  var MemoryCredentialStore = class {
1699
1815
  credential = null;
@@ -1725,7 +1841,7 @@ var InsecureFileCredentialStore = class {
1725
1841
  });
1726
1842
  }
1727
1843
  async clear() {
1728
- await (0, import_promises2.rm)(this.filePath, { force: true });
1844
+ await (0, import_promises3.rm)(this.filePath, { force: true });
1729
1845
  }
1730
1846
  };
1731
1847
  var KeyringCredentialStore = class {
@@ -1752,7 +1868,7 @@ var KeyringCredentialStore = class {
1752
1868
  }
1753
1869
  async clear() {
1754
1870
  await this.keyring.deletePassword();
1755
- await (0, import_promises2.rm)(this.filePath, { force: true });
1871
+ await (0, import_promises3.rm)(this.filePath, { force: true });
1756
1872
  }
1757
1873
  };
1758
1874
  function createCredentialStore(options = {}) {
@@ -1770,14 +1886,14 @@ function defaultKeyringAdapter() {
1770
1886
  };
1771
1887
  }
1772
1888
  function credentialPath(configDir) {
1773
- return (0, import_node_path.join)(
1774
- configDir ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".config", "dropthis"),
1889
+ return (0, import_node_path2.join)(
1890
+ configDir ?? (0, import_node_path2.join)((0, import_node_os.homedir)(), ".config", "dropthis"),
1775
1891
  "credentials.json"
1776
1892
  );
1777
1893
  }
1778
1894
  async function readJsonFile(path) {
1779
1895
  try {
1780
- return JSON.parse(await (0, import_promises2.readFile)(path, "utf8"));
1896
+ return JSON.parse(await (0, import_promises3.readFile)(path, "utf8"));
1781
1897
  } catch (error2) {
1782
1898
  if (error2 && typeof error2 === "object" && "code" in error2 && error2.code === "ENOENT") {
1783
1899
  return null;
@@ -1786,14 +1902,14 @@ async function readJsonFile(path) {
1786
1902
  }
1787
1903
  }
1788
1904
  async function writeCredentialFile(path, credential) {
1789
- await (0, import_promises2.mkdir)((0, import_node_path.dirname)(path), { recursive: true });
1905
+ await (0, import_promises3.mkdir)((0, import_node_path2.dirname)(path), { recursive: true });
1790
1906
  const tmpPath = `${path}.${process.pid}.tmp`;
1791
- await (0, import_promises2.writeFile)(tmpPath, `${JSON.stringify(credential, null, 2)}
1907
+ await (0, import_promises3.writeFile)(tmpPath, `${JSON.stringify(credential, null, 2)}
1792
1908
  `, {
1793
1909
  mode: 384
1794
1910
  });
1795
- await (0, import_promises2.chmod)(tmpPath, 384);
1796
- await (0, import_promises2.rename)(tmpPath, path);
1911
+ await (0, import_promises3.chmod)(tmpPath, 384);
1912
+ await (0, import_promises3.rename)(tmpPath, path);
1797
1913
  }
1798
1914
 
1799
1915
  // src/program.ts
@@ -1822,7 +1938,7 @@ function buildProgram(options = {}) {
1822
1938
  ` ${import_picocolors4.default.cyan("dropthis login")}`,
1823
1939
  ` ${import_picocolors4.default.cyan("dropthis publish ./dist")}`
1824
1940
  ].join("\n")
1825
- ).version("0.9.3").configureHelp({
1941
+ ).version("0.10.0").configureHelp({
1826
1942
  subcommandTerm(cmd) {
1827
1943
  const args = cmd.registeredArguments.map((a) => {
1828
1944
  const name = a.name();
@@ -1984,6 +2100,20 @@ function buildProgram(options = {}) {
1984
2100
  process.exitCode = result.exitCode;
1985
2101
  }
1986
2102
  );
2103
+ program.command("pull <target>").description(
2104
+ "Download a drop's files into a local directory (accepts the drop_\u2026 id, the drop URL, or its slug).\nFetches the current deployment's file manifest and writes every file. Pull, edit, then update-content to ship the changes back to the same URL \u2014 also the rollback path."
2105
+ ).option("-o, --output <dir>", "Output directory (default: ./<slug-or-id>)").option("--json", "Force JSON output").action(
2106
+ async (target, commandOptions) => {
2107
+ const deps = await commandDeps(
2108
+ program,
2109
+ options,
2110
+ store,
2111
+ commandOptions
2112
+ );
2113
+ const result = await runPull(target, commandOptions, deps);
2114
+ process.exitCode = result.exitCode;
2115
+ }
2116
+ );
1987
2117
  program.command("get <dropId>").description("Show drop details (pass the full drop_\u2026 id)").option("--json", "Force JSON output").action(async (dropId, commandOptions) => {
1988
2118
  const deps = await commandDeps(program, options, store, commandOptions);
1989
2119
  const result = await runDropsGet(dropId, commandOptions, deps);