@watermarkws/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Much Media Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,68 @@
1
- # Temporary Holding Version
1
+ # Watermark CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Watermark, resize and convert images, videos and PDFs from the command line with the [Watermark API](https://api.watermark.ws/docs). Built for people and for AI agents: `--json` prints machine-readable output and exit codes mean something.
4
+
5
+ ```bash
6
+ export WATERMARK_API_KEY=wm_test_… # https://watermark.ws/developers
7
+ npx @watermarkws/cli image process https://example.com/photo.jpg \
8
+ --op resize:width=1600,height=1600 \
9
+ --op watermark:image_url=https://example.com/logo.png,x=40,y=40 \
10
+ --format webp --out photo.webp
11
+ ```
12
+
13
+ Node 18+, no dependencies.
14
+
15
+ ## Commands
16
+
17
+ ```
18
+ watermark image process <source> --op <operation> [--op ...] [--format webp] [--out file]
19
+ watermark image read <source> [--level] # --level also finds the angle that levels a tilted horizon
20
+ watermark image create --width 1200 --height 300 --op 'text:text=Hi,font=inter-bold,size=96' --op trim --out logo.png
21
+ watermark video process <source> --op <operation> [--max-duration 30] [--out file]
22
+ watermark video read <source> | watermark video screenshot <source> --seconds 5
23
+ watermark document process <source> --op <operation> [--max-pages 10] [--out file]
24
+ watermark document read <source> | watermark document snapshot <source> --page 1
25
+ watermark request get <id> | watermark request abort <id>
26
+ watermark requests [--before <id>] | watermark account | watermark usage --group-by endpoint [--customer <id>]
27
+ watermark upload photo.heic | watermark upload get|delete <upl_id>
28
+ watermark file get|delete <file_id>
29
+ watermark storage list | connect --bucket b --region us-east-1 --role-arn <arn> --prefix in/ [--read true|false] [--write true|false] | get|verify|disconnect <stg_id>
30
+ watermark storage update <stg_id> --prefix photos/ --prefix archive/ [--read true|false] [--write true|false] # the whole new list; checked before it is kept
31
+ watermark events --since 2026-10-13T15:00:00Z
32
+ watermark fonts
33
+ watermark operations image
34
+ ```
35
+
36
+ Processing commands wait for the request to finish (up to `--timeout`, default 300 seconds) unless `--no-wait` is given; `--out` downloads the result.
37
+
38
+ Operations run in order: `--op type:param=value,param=value`, or all at once with `--ops-json '[{"type":"resize","width":800,"height":600}]'`. `watermark operations` lists them all.
39
+
40
+ More than one file from a request, each with its own operations and format: `--outputs-json '[{"name":"large","format":"webp"},{"name":"thumb","operations":[{"type":"resize","width":200,"height":200}]}]'`.
41
+
42
+ Other options: `--webhook <url>`, `--event <type>` (repeatable, such as `request.progress`), `--customer <id>`, `--metadata key=value`, `--idempotency-key <key>`, `--json`, `--api-key`, `--base-url` (or `WATERMARK_API_URL`).
43
+
44
+ A source is a public http(s) URL, a local file (uploaded first), an upload
45
+ (`upl_…`), a file an earlier request kept (`file_…`), or an object in a
46
+ connected bucket (`stg_7a:path/to/key`).
47
+
48
+ ## Errors
49
+
50
+ The CLI passes every error code through as the API sends it: `Error 422 validation_failed: …`, with every invalid field below it, or with `--json` the whole error object (`type`, `code`, `message`, `param`, `request_id`, `errors`, and the `permissions_policy` a storage change whose check failed needs). The codes are listed at https://api.watermark.ws/docs/errors.md.
51
+
52
+ ## Exit codes
53
+
54
+ | Code | Meaning |
55
+ | --- | --- |
56
+ | 0 | Succeeded (or queued, with `--no-wait`). |
57
+ | 1 | The API refused the call, or the request failed or was aborted. The error code is printed. |
58
+ | 2 | The command was used wrongly. |
59
+
60
+ Rate limits (429) and server errors are retried twice with the same `Idempotency-Key`, so a retry never runs a request twice.
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ npm test
66
+ ```
67
+
68
+ `test/spec-coverage.test.js` holds the CLI to the API's OpenAPI spec: every endpoint has a command, every parameter is sent, and the spec is the one in `spec/openapi.json`, whose sha256 is in `spec.sha`. It reads the spec from `WATERMARK_API_SPEC` (a path or URL), else `../watermark-api`, else `spec/openapi.json`. When the API changes, update the CLI, then run `npm run spec:refresh` to record the new spec as checked.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2), { env: process.env, stdout: process.stdout, stderr: process.stderr })
5
+ .then((code) => { process.exitCode = code; });
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@watermarkws/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Watermark, resize and convert images, videos and PDFs from the command line with the Watermark API.",
5
+ "type": "module",
6
+ "bin": {
7
+ "watermark": "bin/watermark.js"
8
+ },
9
+ "files": ["bin", "src", "README.md", "LICENSE"],
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test test/",
15
+ "spec:refresh": "node scripts/refresh-spec.js"
16
+ },
17
+ "keywords": ["watermark", "image", "video", "pdf", "cli", "api"],
18
+ "homepage": "https://api.watermark.ws/docs",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/watermarkws/watermark-cli.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/watermarkws/watermark-cli/issues"
25
+ },
26
+ "license": "MIT",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }
package/src/api.js ADDED
@@ -0,0 +1,123 @@
1
+ // A small client for the Watermark API: the key, JSON, typed errors, and
2
+ // retries for 429 (after Retry-After), 5xx and dropped connections, with an
3
+ // Idempotency-Key on every POST so a retried POST never runs twice.
4
+
5
+ export const VERSION = '0.1.0';
6
+
7
+ export class ApiError extends Error {
8
+ constructor(status, error = {}) {
9
+ super(error.message || `The API answered ${status}.`);
10
+ this.status = status;
11
+ this.type = error.type || 'server_error';
12
+ this.code = error.code || 'unknown';
13
+ this.param = error.param;
14
+ this.requestId = error.request_id;
15
+ this.errors = error.errors;
16
+ // The whole error object as the API sent it, with the fields some errors
17
+ // add, such as a storage change's permissions_policy.
18
+ this.error = error;
19
+ }
20
+ }
21
+
22
+ export class Api {
23
+ constructor({ apiKey, baseUrl = 'https://api.watermark.ws', fetch = globalThis.fetch, sleep, maxRetries = 2 }) {
24
+ if (!apiKey) {
25
+ throw new Error('Set WATERMARK_API_KEY or pass --api-key. Get a key at https://watermark.ws/developers');
26
+ }
27
+ this.apiKey = apiKey;
28
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
29
+ this.fetch = fetch;
30
+ this.sleep = sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
31
+ this.maxRetries = maxRetries;
32
+ }
33
+
34
+ async request(method, path, { body, query, idempotencyKey } = {}) {
35
+ const url = new URL(this.baseUrl + path);
36
+ for (const [key, value] of Object.entries(query || {})) {
37
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
38
+ }
39
+
40
+ const headers = {
41
+ Authorization: `Bearer ${this.apiKey}`,
42
+ Accept: 'application/json',
43
+ 'User-Agent': `watermark-cli/${VERSION} node/${process.versions.node}`,
44
+ };
45
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
46
+ if (method === 'POST') headers['Idempotency-Key'] = idempotencyKey || crypto.randomUUID();
47
+
48
+ for (let attempt = 0; ; attempt++) {
49
+ let response;
50
+ try {
51
+ response = await this.fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
52
+ } catch (error) {
53
+ if (attempt >= this.maxRetries) throw error;
54
+ await this.sleep(500 * 2 ** attempt);
55
+ continue;
56
+ }
57
+
58
+ if ((response.status === 429 || response.status >= 500) && attempt < this.maxRetries) {
59
+ const retryAfter = Number(response.headers.get('retry-after'));
60
+ await this.sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 60) * 1000 : 500 * 2 ** attempt);
61
+ continue;
62
+ }
63
+
64
+ const text = await response.text();
65
+ const json = text ? JSON.parse(text) : {};
66
+ if (response.status >= 400) throw new ApiError(response.status, json.error);
67
+ return json;
68
+ }
69
+ }
70
+
71
+ // Wait for a request to be succeeded, failed or aborted. Each call asks the
72
+ // API to hold the response until the request finishes (up to 25 seconds),
73
+ // so the result comes back as soon as it is ready.
74
+ async wait(id, { timeoutMs = 300_000, intervalMs = 1000, onProgress } = {}) {
75
+ const started = Date.now();
76
+ for (;;) {
77
+ const hold = Math.max(0, Math.min(25, Math.floor((timeoutMs - (Date.now() - started)) / 1000)));
78
+ const before = Date.now();
79
+ const request = await this.request('GET', `/v1/requests/${id}`, { query: { wait: hold || undefined } });
80
+ if (['succeeded', 'failed', 'aborted'].includes(request.status)) return request;
81
+ if (onProgress) onProgress(request);
82
+ if (Date.now() - started >= timeoutMs) {
83
+ throw new Error(`Request ${id} was still ${request.status} after ${Math.round(timeoutMs / 1000)} seconds.`);
84
+ }
85
+ if (hold > 0 && Date.now() - before >= (hold - 1) * 1000) continue;
86
+ await this.sleep(intervalMs);
87
+ intervalMs = Math.min(5000, intervalMs * 1.5);
88
+ }
89
+ }
90
+
91
+ // Make an upload for a local file and PUT the file to its URL. Its id is
92
+ // then a request's source.
93
+ async upload(path, { readFile, contentType } = {}) {
94
+ const bytes = await readFile(path);
95
+ const filename = path.split(/[\\/]/).pop();
96
+ const type = contentType || contentTypeOf(filename);
97
+ const upload = await this.request('POST', '/v1/uploads', { body: { filename, size: bytes.length, content_type: type } });
98
+
99
+ const response = await this.fetch(upload.upload.url, {
100
+ method: 'PUT',
101
+ headers: upload.upload.headers || { 'Content-Type': type },
102
+ body: bytes,
103
+ });
104
+ if (!response.ok) throw new Error(`Sending ${filename} failed: HTTP ${response.status}.`);
105
+
106
+ return upload;
107
+ }
108
+ }
109
+
110
+ const CONTENT_TYPES = {
111
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif',
112
+ heic: 'image/heic', heif: 'image/heif', tif: 'image/tiff', tiff: 'image/tiff',
113
+ bmp: 'image/bmp', svg: 'image/svg+xml', psd: 'image/vnd.adobe.photoshop',
114
+ cr2: 'image/x-canon-cr2', cr3: 'image/x-canon-cr3', nef: 'image/x-nikon-nef', arw: 'image/x-sony-arw',
115
+ dng: 'image/x-adobe-dng', raf: 'image/x-fuji-raf', orf: 'image/x-olympus-orf', rw2: 'image/x-panasonic-rw2',
116
+ mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/x-m4v', webm: 'video/webm', mkv: 'video/x-matroska',
117
+ avi: 'video/x-msvideo', flv: 'video/x-flv', wmv: 'video/x-ms-wmv', mpg: 'video/mpeg',
118
+ pdf: 'application/pdf', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
119
+ };
120
+
121
+ export function contentTypeOf(filename) {
122
+ return CONTENT_TYPES[(filename.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
123
+ }
package/src/cli.js ADDED
@@ -0,0 +1,407 @@
1
+ import { readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { Api, ApiError, VERSION } from './api.js';
3
+
4
+ const HELP = `watermark: the Watermark API from the command line (https://api.watermark.ws/docs)
5
+
6
+ Usage:
7
+ watermark image process <source> --op <operation> [--op ...] [--format webp] [--out file]
8
+ watermark image read <source> [--level]
9
+ watermark image create --width 1200 --height 300 [--background "#ffffff"] --op <operation> [--out file]
10
+ watermark video process <source> --op <operation> [--max-duration 30] [--out file]
11
+ watermark video read <source>
12
+ watermark video screenshot <source> --seconds 5 [--format png] [--out file]
13
+ watermark document process <source> --op <operation> [--max-pages 10] [--out file]
14
+ watermark document read <source>
15
+ watermark document snapshot <source> [--page 1] [--format png] [--out file]
16
+ watermark request get <id> | watermark request abort <id>
17
+ watermark requests [--limit 20] [--before <id>]
18
+ watermark upload <file> | watermark upload get|delete <upl_id>
19
+ watermark file get|delete <file_id>
20
+ watermark storage list | get|verify|disconnect <stg_id>
21
+ watermark storage connect --bucket <name> --region <region> --role-arn <arn> --prefix in/ [--prefix ...] [--read true|false] [--write true|false]
22
+ watermark storage update <stg_id> [--prefix in/ ...] [--read true|false] [--write true|false]
23
+ watermark events --since 2026-10-13T15:00:00Z [--limit 100]
24
+ watermark fonts
25
+ watermark account
26
+ watermark usage [--from 2026-10-01] [--to 2026-10-31] [--group-by day|endpoint|customer|status] [--customer <id>]
27
+ watermark operations [image|video|document]
28
+
29
+ A source is a public http(s) URL, a local file (uploaded first), an upload
30
+ (upl_…), a file an earlier request kept (file_…), or an object in a
31
+ connected bucket (stg_7a:path/to/key).
32
+
33
+ Operations (repeat --op; they run in order):
34
+ --op resize:width=800,height=600
35
+ --op grayscale
36
+ --op watermark:image_url=https://example.com/logo.png,x=40,y=40
37
+ --ops-json '[{"type":"resize","width":800,"height":600}]'
38
+ See: watermark operations image
39
+
40
+ More than one file from a request, each with its own operations and format:
41
+ --outputs-json '[{"name":"large","format":"webp"},{"name":"thumb","operations":[{"type":"resize","width":200,"height":200}]}]'
42
+
43
+ Options:
44
+ --out <file> Wait, then download the result to this file.
45
+ --no-wait Return as soon as the request is queued.
46
+ --timeout <seconds> How long to wait (default 300).
47
+ --webhook <url> Post the finished request here.
48
+ --event <type> The webhook events to send (repeatable), such as request.progress.
49
+ --customer <id> Your own reference for who the request is for.
50
+ --metadata key=value Repeatable.
51
+ --idempotency-key <k> Make a retry of this command safe.
52
+ --json Print JSON only (for scripts and AI agents).
53
+ --api-key <key> Or set WATERMARK_API_KEY.
54
+ --base-url <url> Or set WATERMARK_API_URL (default https://api.watermark.ws).
55
+ --version Print the version.
56
+ `;
57
+
58
+ const MEDIA = { image: 'images', images: 'images', video: 'videos', videos: 'videos', document: 'documents', documents: 'documents', pdf: 'documents' };
59
+ const ACTIONS = {
60
+ images: ['process', 'read', 'create'],
61
+ videos: ['process', 'read', 'screenshot'],
62
+ documents: ['process', 'read', 'snapshot'],
63
+ };
64
+ const REPEATABLE = new Set(['op', 'metadata', 'prefix', 'event']);
65
+ const FLAGS = new Set(['json', 'no-wait', 'help', 'version', 'level']);
66
+
67
+ // Parse argv into positionals and options; repeatable options collect arrays.
68
+ export function parseArgs(argv) {
69
+ const positionals = [];
70
+ const options = { op: [], metadata: [], prefix: [], event: [] };
71
+
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const arg = argv[i];
74
+ if (!arg.startsWith('--')) {
75
+ positionals.push(arg);
76
+ continue;
77
+ }
78
+ let [name, value] = arg.slice(2).split(/=(.*)/s, 2);
79
+ if (FLAGS.has(name)) {
80
+ options[name] = true;
81
+ continue;
82
+ }
83
+ if (value === undefined) {
84
+ value = argv[++i];
85
+ if (value === undefined) throw new UsageError(`--${name} needs a value.`);
86
+ }
87
+ if (REPEATABLE.has(name)) options[name].push(value);
88
+ else options[name] = value;
89
+ }
90
+
91
+ return { positionals, options };
92
+ }
93
+
94
+ // "resize:width=800,height=600" -> {type: "resize", width: 800, height: 600}
95
+ export function parseOperation(text) {
96
+ const [type, rest] = text.split(/:(.*)/s, 2);
97
+ if (!/^[a-z_]+$/.test(type)) throw new UsageError(`"${text}" is not an operation: use type:param=value,...`);
98
+
99
+ const operation = { type };
100
+ for (const pair of (rest || '').split(',').filter(Boolean)) {
101
+ const [key, value] = pair.split(/=(.*)/s, 2);
102
+ if (value === undefined) throw new UsageError(`"${pair}" in --op ${type} needs a value: param=value.`);
103
+ operation[key.trim()] = typedValue(value.trim());
104
+ }
105
+ return operation;
106
+ }
107
+
108
+ function typedValue(value) {
109
+ if (value === 'true') return true;
110
+ if (value === 'false') return false;
111
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
112
+ return value;
113
+ }
114
+
115
+ class UsageError extends Error {}
116
+
117
+ export async function main(argv, { env = {}, stdout, stderr, fetch, sleep } = {}) {
118
+ const print = (text) => stdout.write(text + '\n');
119
+ const fail = (text) => stderr.write(text + '\n');
120
+
121
+ let parsed;
122
+ try {
123
+ parsed = parseArgs(argv);
124
+ } catch (error) {
125
+ fail(error.message);
126
+ return 2;
127
+ }
128
+ const { positionals, options } = parsed;
129
+
130
+ if (options.version) {
131
+ print(`watermark ${VERSION}`);
132
+ return 0;
133
+ }
134
+
135
+ if (options.help || positionals.length === 0) {
136
+ print(HELP);
137
+ return positionals.length === 0 && !options.help ? 2 : 0;
138
+ }
139
+
140
+ const json = Boolean(options.json);
141
+ const output = (value, human) => print(json ? JSON.stringify(value, null, 2) : human(value));
142
+
143
+ try {
144
+ const api = new Api({
145
+ apiKey: options['api-key'] || env.WATERMARK_API_KEY,
146
+ baseUrl: options['base-url'] || env.WATERMARK_API_URL || 'https://api.watermark.ws',
147
+ fetch,
148
+ sleep,
149
+ });
150
+
151
+ const [command, ...rest] = positionals;
152
+
153
+ if (MEDIA[command]) {
154
+ return await runMedia(api, MEDIA[command], rest, options, { output, print, json, fetch: fetch || globalThis.fetch });
155
+ }
156
+
157
+ switch (command) {
158
+ case 'request': {
159
+ const [action, id] = rest;
160
+ if (!['get', 'abort'].includes(action) || !/^\d+$/.test(id || '')) throw new UsageError('Usage: watermark request get|abort <id>');
161
+ const request = await api.request(action === 'get' ? 'GET' : 'POST', `/v1/requests/${id}${action === 'abort' ? '/abort' : ''}`);
162
+ output(request, describeRequest);
163
+ return request.status === 'failed' ? 1 : 0;
164
+ }
165
+ case 'requests': {
166
+ const list = await api.request('GET', '/v1/requests', { query: { limit: options.limit, before: options.before } });
167
+ output(list, (value) => value.data.map((request) => `${request.id}\t${request.endpoint}\t${request.status}\t${request.created_at}`).join('\n') || 'No requests yet.');
168
+ return 0;
169
+ }
170
+ case 'account': {
171
+ const account = await api.request('GET', '/v1/account');
172
+ output(account, (value) => [
173
+ `${value.name} (${value.tier}${value.livemode === false ? ', test key' : ''})`,
174
+ `Balance: $${value.balance.amount}`,
175
+ value.limits ? `Limits: ${value.limits.requests_per_minute} requests a minute, sources up to ${Math.round(value.limits.max_source_bytes / 1048576)} MB` : '',
176
+ ].filter(Boolean).join('\n'));
177
+ return 0;
178
+ }
179
+ case 'usage': {
180
+ const usage = await api.request('GET', '/v1/usage', { query: { from: options.from, to: options.to, group_by: options['group-by'], customer: options.customer } });
181
+ output(usage, (value) => [`${value.group_by}\trequests\tfailed\tspent`, ...value.data.map((row) => `${row[value.group_by]}\t${row.requests}\t${row.failed}\t$${row.spent}`)].join('\n'));
182
+ return 0;
183
+ }
184
+ case 'operations': {
185
+ const catalog = await api.request('GET', '/v1/operations');
186
+ const media = rest[0] ? { image: 'image', images: 'image', video: 'video', videos: 'video', document: 'document', documents: 'document' }[rest[0]] : null;
187
+ const selected = media ? { [media]: catalog.data[media] } : catalog.data;
188
+ output(selected, (value) => Object.entries(value).map(([name, entry]) => [
189
+ `${name} (formats: ${entry.formats.join(', ')})`,
190
+ ...Object.entries(entry.operations).map(([type, operation]) => ` ${type}: ${operation.description}\n${Object.entries(operation.params).map(([param, info]) => ` ${param}${info.required ? ' (required)' : ''}: ${info.description}`).join('\n')}`),
191
+ ].join('\n')).join('\n\n'));
192
+ return 0;
193
+ }
194
+ case 'upload': {
195
+ const [action, id] = rest;
196
+ if (['get', 'delete'].includes(action)) {
197
+ if (!/^upl_[A-Za-z0-9]{22}$/.test(id || '')) throw new UsageError('Usage: watermark upload get|delete <upl_id>');
198
+ output(await api.request(action === 'get' ? 'GET' : 'DELETE', `/v1/uploads/${id}`), describeUpload);
199
+ return 0;
200
+ }
201
+ if (!action) throw new UsageError('Usage: watermark upload <file>');
202
+ output(await api.upload(action, { readFile }), describeUpload);
203
+ return 0;
204
+ }
205
+ case 'file': {
206
+ const [action, id] = rest;
207
+ if (!['get', 'delete'].includes(action) || !/^file_[A-Za-z0-9]{22}$/.test(id || '')) throw new UsageError('Usage: watermark file get|delete <file_id>');
208
+ output(await api.request(action === 'get' ? 'GET' : 'DELETE', `/v1/files/${id}`), describeFile);
209
+ return 0;
210
+ }
211
+ case 'storage': {
212
+ const [action, id] = rest;
213
+ if (action === 'list' || !action) {
214
+ output(await api.request('GET', '/v1/storage'), (value) => value.data.map(describeStorage).join('\n\n') || 'No storage connections.');
215
+ return 0;
216
+ }
217
+ if (action === 'connect') {
218
+ if (!options.bucket || !options.region || !options['role-arn'] || !options.prefix.length) throw new UsageError('Usage: watermark storage connect --bucket <name> --region <region> --role-arn <arn> --prefix in/ [--prefix ...] [--read true|false] [--write true|false]');
219
+ const body = { provider: 's3', bucket: options.bucket, region: options.region, role_arn: options['role-arn'], prefixes: options.prefix, ...storageSwitches(options) };
220
+ output(await api.request('POST', '/v1/storage', { body }), describeStorage);
221
+ return 0;
222
+ }
223
+ if (action === 'update') {
224
+ const usage = 'Usage: watermark storage update <stg_id> [--prefix in/ ...] [--read true|false] [--write true|false]';
225
+ if (!/^stg_[0-9a-z]{1,12}$/.test(id || '')) throw new UsageError(usage);
226
+ const body = storageSwitches(options);
227
+ if (options.prefix.length) body.prefixes = options.prefix;
228
+ if (!Object.keys(body).length) throw new UsageError(usage);
229
+ output(await api.request('PATCH', `/v1/storage/${id}`, { body }), describeStorage);
230
+ return 0;
231
+ }
232
+ if (!['get', 'verify', 'disconnect'].includes(action) || !/^stg_[0-9a-z]{1,12}$/.test(id || '')) throw new UsageError('Usage: watermark storage list | get|verify|disconnect <stg_id> | connect ... | update ...');
233
+ const method = { get: 'GET', verify: 'POST', disconnect: 'DELETE' }[action];
234
+ const storage = await api.request(method, `/v1/storage/${id}${action === 'verify' ? '/verify' : ''}`);
235
+ output(storage, describeStorage);
236
+ return action === 'verify' && storage.status !== 'verified' ? 1 : 0;
237
+ }
238
+ case 'events': {
239
+ if (!options.since) throw new UsageError('Usage: watermark events --since 2026-10-13T15:00:00Z [--limit 100]');
240
+ const events = await api.request('GET', '/v1/events', { query: { since: options.since, limit: options.limit } });
241
+ output(events, (value) => [
242
+ ...value.data.map((event) => `${new Date(event.created * 1000).toISOString()}\t${event.type}\trequest ${event.data?.id}`),
243
+ value.has_more ? `More: watermark events --since ${new Date(value.data.at(-1).created * 1000).toISOString()}` : '',
244
+ ].filter(Boolean).join('\n') || 'No events since then.');
245
+ return 0;
246
+ }
247
+ case 'fonts': {
248
+ const fonts = await api.request('GET', '/v1/fonts');
249
+ output(fonts, (value) => value.data.map((font) => `${font.name}\t${[font.family, font.style].filter(Boolean).join(' ')}`).join('\n'));
250
+ return 0;
251
+ }
252
+ default:
253
+ throw new UsageError(`Unknown command "${command}". Run watermark --help.`);
254
+ }
255
+ } catch (error) {
256
+ if (error instanceof UsageError) {
257
+ fail(error.message);
258
+ return 2;
259
+ }
260
+ if (error instanceof ApiError) {
261
+ if (json) print(JSON.stringify({ error: { ...error.error, status: error.status, type: error.type, code: error.code, message: error.message } }, null, 2));
262
+ else fail(describeError(error));
263
+ return 1;
264
+ }
265
+ fail(error.message);
266
+ return 1;
267
+ }
268
+ }
269
+
270
+ async function runMedia(api, media, [action, source], options, { output, print, json, fetch }) {
271
+ if (!ACTIONS[media].includes(action)) throw new UsageError(`Usage: watermark ${media.replace(/s$/, '')} ${ACTIONS[media].join('|')} <source>`);
272
+
273
+ const body = {};
274
+
275
+ if (action === 'create') {
276
+ if (!options.width || !options.height) throw new UsageError('watermark image create needs --width and --height.');
277
+ body.width = Number(options.width);
278
+ body.height = Number(options.height);
279
+ if (options.background) body.background = options.background;
280
+ } else {
281
+ body.source = await resolveSource(api, source);
282
+ }
283
+
284
+ if (options.level) {
285
+ if (media !== 'images' || action !== 'read') throw new UsageError('--level is for watermark image read.');
286
+ body.level = true;
287
+ }
288
+
289
+ if (action === 'process' || action === 'create') {
290
+ const operations = options['ops-json'] ? JSON.parse(options['ops-json']) : options.op.map(parseOperation);
291
+ if (operations.length === 0) throw new UsageError('Give at least one --op (see: watermark operations).');
292
+ body.operations = operations;
293
+ }
294
+ if (options['outputs-json']) body.outputs = JSON.parse(options['outputs-json']);
295
+ if (options.format) body.output = { format: options.format };
296
+ else if (action === 'create' && options.out) body.output = { format: options.out.split('.').pop().toLowerCase() };
297
+ if (options.seconds !== undefined) body.seconds = Number(options.seconds);
298
+ if (action === 'screenshot' && body.seconds === undefined) throw new UsageError('A screenshot needs --seconds.');
299
+ if (options.page !== undefined) body.page = Number(options.page);
300
+ if (options['max-duration'] !== undefined) body.max_duration = Number(options['max-duration']);
301
+ if (options['max-pages'] !== undefined) body.max_pages = Number(options['max-pages']);
302
+ if (options.webhook) body.webhook_url = options.webhook;
303
+ if (options.event.length) body.events = options.event;
304
+ if (options.customer) body.customer = options.customer;
305
+ if (options.metadata.length) {
306
+ body.metadata = Object.fromEntries(options.metadata.map((pair) => {
307
+ const [key, value] = pair.split(/=(.*)/s, 2);
308
+ if (value === undefined) throw new UsageError(`--metadata needs key=value, got "${pair}".`);
309
+ return [key, value];
310
+ }));
311
+ }
312
+
313
+ let request = await api.request('POST', `/v1/${media}/${action}`, { body, idempotencyKey: options['idempotency-key'] });
314
+
315
+ if (!options['no-wait']) {
316
+ request = await api.wait(request.id, {
317
+ timeoutMs: Number(options.timeout || 300) * 1000,
318
+ onProgress: json ? undefined : (current) => process.stderr.write(`Request ${current.id}: ${current.status}${current.progress !== undefined ? ` ${current.progress}%` : ''}\r`),
319
+ });
320
+ }
321
+
322
+ if (options.out && request.status === 'succeeded') {
323
+ const url = request.result?.files?.[0]?.url;
324
+ if (!url) throw new Error(`Request ${request.id} made no file to download.`);
325
+ const response = await fetch(url);
326
+ if (!response.ok) throw new Error(`Downloading the result failed: HTTP ${response.status}.`);
327
+ await writeFile(options.out, Buffer.from(await response.arrayBuffer()));
328
+ request = { ...request, saved_to: options.out };
329
+ }
330
+
331
+ output(request, describeRequest);
332
+
333
+ return request.status === 'failed' || request.status === 'aborted' ? 1 : 0;
334
+ }
335
+
336
+ // A source as the API takes it: a URL, an id, a storage object, or a local
337
+ // file, which is uploaded first.
338
+ async function resolveSource(api, source) {
339
+ if (!source) throw new UsageError('Give a source: a URL, a local file, upl_…, file_… or stg_…:key.');
340
+ if (/^https?:\/\//.test(source) || /^(upl|file)_[A-Za-z0-9]{22}$/.test(source)) return source;
341
+
342
+ const object = source.match(/^(stg_[0-9a-z]{1,12}):(.+)$/s);
343
+ if (object) return { storage: object[1], key: object[2] };
344
+
345
+ const local = await stat(source).catch(() => null);
346
+ if (!local?.isFile()) throw new UsageError(`"${source}" is not a URL, an id, stg_…:key, or a file here.`);
347
+
348
+ const upload = await api.upload(source, { readFile });
349
+ return upload.id;
350
+ }
351
+
352
+ // --read and --write as the API takes them, for connecting or changing storage.
353
+ function storageSwitches(options) {
354
+ const switches = {};
355
+ for (const field of ['read', 'write']) {
356
+ if (options[field] === undefined) continue;
357
+ if (!['true', 'false'].includes(options[field])) throw new UsageError(`--${field} takes true or false.`);
358
+ switches[field] = options[field] === 'true';
359
+ }
360
+ return switches;
361
+ }
362
+
363
+ // An API error for people: the code to branch on, every invalid field, and
364
+ // the permissions policy a storage change whose check failed needs.
365
+ function describeError(error) {
366
+ const lines = [`Error ${error.status} ${error.code}: ${error.message}${error.param ? ` (${error.param})` : ''}`];
367
+ for (const [field, messages] of Object.entries(error.errors || {})) {
368
+ lines.push(` ${field}: ${[].concat(messages).join(' ')}`);
369
+ }
370
+ if (error.error.permissions_policy) {
371
+ lines.push(`The role needs this permissions policy:\n${JSON.stringify(error.error.permissions_policy, null, 2)}`);
372
+ }
373
+ return lines.join('\n');
374
+ }
375
+
376
+ function describeUpload(upload) {
377
+ return `Upload ${upload.id} (${upload.filename}): ${upload.status}${upload.expires_at ? `, until ${upload.expires_at}` : ''}`;
378
+ }
379
+
380
+ function describeFile(file) {
381
+ const lines = [`File ${file.id} (${file.name}, ${file.format}, ${file.size} bytes)`];
382
+ if (file.url) lines.push(`Download: ${file.url} (until ${file.url_expires_at})`);
383
+ if (file.destination) lines.push(`Written to: ${file.destination.storage ? `${file.destination.storage}:${file.destination.key}` : 'your URL'}`);
384
+ if (file.deleted_at && !file.url) lines.push(`Deleted ${file.deleted_at}`);
385
+ return lines.join('\n');
386
+ }
387
+
388
+ function describeStorage(storage) {
389
+ const lines = [`${storage.id}: s3://${storage.bucket} (${storage.region}), ${storage.status}`];
390
+ if (storage.prefixes?.length) lines.push(`Prefixes: ${storage.prefixes.join(', ')}`);
391
+ if (storage.status !== 'verified' && storage.trust_policy) lines.push(`Create the role with the trust_policy and permissions_policy shown with --json (external id ${storage.external_id}), then run: watermark storage verify ${storage.id}`);
392
+ if (storage.last_error) lines.push(`Problem: ${storage.last_error}`);
393
+ return lines.join('\n');
394
+ }
395
+
396
+ function describeRequest(request) {
397
+ const lines = [`Request ${request.id} (${request.endpoint}): ${request.status}`];
398
+ if (request.status === 'failed' && request.error) lines.push(`Error ${request.error.code}: ${request.error.message}`);
399
+ if (request.result?.data) lines.push(`Result: ${JSON.stringify(request.result.data)}`);
400
+ for (const file of request.result?.files || []) {
401
+ if (file.destination) lines.push(`File: written to ${file.destination.storage ? `${file.destination.storage}:${file.destination.key}` : 'your URL'}`);
402
+ else lines.push(file.url ? `File: ${file.url} (link valid until ${file.url_expires_at})` : 'File: made, but no download link is available right now; fetch the request again.');
403
+ }
404
+ if (request.saved_to) lines.push(`Saved to ${request.saved_to}`);
405
+ if (request.price) lines.push(`Price: $${request.price.amount}`);
406
+ return lines.join('\n');
407
+ }