@dropthis/cli 0.9.2 → 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) |
@@ -126,48 +127,78 @@ These flags are inherited by all commands:
126
127
 
127
128
  ## Update
128
129
 
129
- Update an existing drop's content, metadata, or both via `drops update`:
130
+ Change an existing drop without creating a new URL. Content and settings are **separate** operations — pass the full `drop_…` id (not the slug):
130
131
 
131
132
  ```bash
132
- # Replace content
133
- dropthis drops update drop_abc123 ./dist-v2
133
+ # Replace the content at the same URL (ships a new deployment)
134
+ dropthis update-content drop_abc123 ./dist-v2
135
+
136
+ # Change settings only — title, visibility, password, expiry, metadata
137
+ dropthis update-settings drop_abc123 --title "New title"
138
+
139
+ # Optimistic concurrency (both accept --if-revision)
140
+ dropthis update-content drop_abc123 ./dist-v2 --if-revision 1
141
+ ```
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.
134
144
 
135
- # Update metadata only
136
- dropthis drops update drop_abc123 --title "New title"
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:
137
146
 
138
- # Optimistic concurrency
139
- dropthis drops update drop_abc123 ./dist-v2 --if-revision 1
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
140
151
  ```
141
152
 
142
- `drops update` supports all publish flags plus `--if-revision`.
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.
143
171
 
144
172
  ## Commands
145
173
 
146
174
  ```bash
147
- dropthis [input...] # Publish content (default command)
148
- dropthis publish [input...] # Same as above, explicit form
175
+ dropthis [input...] # Publish content (default command)
176
+ dropthis publish [input...] # Same as above, explicit form
149
177
 
150
- dropthis drops list # List your drops
151
- dropthis drops get <drop-id> # Get drop details
152
- dropthis drops update <drop-id> [input] # Update content or metadata
153
- dropthis drops delete <drop-id> # Delete a drop
178
+ dropthis update-content <drop-id> [input] # Replace a drop's content (same URL)
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
181
+ dropthis get <drop-id> # Show drop details
182
+ dropthis list # List your drops
183
+ dropthis delete <drop-id> # Delete a drop (--yes to confirm)
154
184
 
155
- dropthis login # Authenticate with email OTP
156
- dropthis login request --email <email> # Request OTP (non-interactive)
157
- dropthis login verify --email <email> --otp <code> # Verify OTP
158
- dropthis logout # Remove stored credentials
159
- dropthis whoami # Show current auth status
160
- dropthis account # Account details
185
+ dropthis deployments # View deployment history
161
186
 
162
- dropthis api-keys create # Create an API key
163
- dropthis api-keys list # List API keys
164
- dropthis api-keys delete <key-id> # Delete an API key
187
+ dropthis login # Authenticate with email OTP
188
+ dropthis login request --email <email> # Request OTP (non-interactive)
189
+ dropthis login verify --email <email> --otp <code> # Verify OTP
190
+ dropthis logout # Remove stored credentials
191
+ dropthis whoami # Show current auth status
165
192
 
166
- dropthis deployments list <drop-id> # List deployments
167
- dropthis deployments get <drop-id> <dep-id> # Get deployment details
193
+ dropthis account # Show account details
194
+ dropthis account update --display-name <name> # Update your display name
168
195
 
169
- dropthis doctor # CLI diagnostics
170
- dropthis commands # Machine-readable command metadata
196
+ dropthis api-keys create # Create an API key
197
+ dropthis api-keys list # List API keys
198
+ dropthis api-keys delete <key-id> # Delete an API key (--yes to confirm)
199
+
200
+ dropthis doctor # CLI diagnostics
201
+ dropthis commands # Machine-readable command metadata
171
202
  ```
172
203
 
173
204
  ## Agent & CI/CD usage
@@ -190,7 +221,7 @@ dropthis publish ./dist --url
190
221
 
191
222
  - Use `--url` to get only the published URL (cleanest for agents)
192
223
  - Use `--json` for the full structured response
193
- - Use `--yes` for destructive commands (`drops delete`, `api-keys delete`)
224
+ - Use `--yes` for destructive commands (`delete`, `api-keys delete`, `account delete`)
194
225
  - Use `--no-interactive` to disable inline auth prompts
195
226
  - All errors write to stderr as JSON with a `next_action` field
196
227
 
@@ -231,6 +262,14 @@ Reports CLI version, auth source (`env`, `flag`, `storage`, or `missing`), and c
231
262
  {"ok":true,"version":"0.4.1","auth":{"source":"env"},"storage":{"backend":"secure"}}
232
263
  ```
233
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
+
234
273
  ## Agent skills
235
274
 
236
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.2"],
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.2",
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.2").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);