@autoposting.ai/cli 0.3.4 → 0.3.5
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/dist/cli.cjs +120 -13
- package/dist/cli.cjs.map +1 -1
- package/package.json +1 -1
package/dist/cli.cjs
CHANGED
|
@@ -42,11 +42,44 @@ function createError(status, body) {
|
|
|
42
42
|
if (status >= 500) return new ServerError(message, status, code);
|
|
43
43
|
return new AutopostingError(message, status, code);
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
function resolvePartSize(fileSize, requested) {
|
|
46
|
+
let size = requested && requested > 0 ? requested : DEFAULT_PART_SIZE;
|
|
47
|
+
if (Math.ceil(fileSize / size) > MAX_PARTS) size = Math.ceil(fileSize / MAX_PARTS);
|
|
48
|
+
return Math.max(size, MIN_PART_SIZE);
|
|
49
|
+
}
|
|
50
|
+
function guessVideoMime(fileName) {
|
|
51
|
+
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
52
|
+
return VIDEO_MIME[ext] ?? "video/mp4";
|
|
53
|
+
}
|
|
54
|
+
async function putPart(url, body) {
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await fetch(url, { method: "PUT", body });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw new AutopostingError(
|
|
60
|
+
`Failed to upload part: ${err instanceof Error ? err.message : String(err)}`,
|
|
61
|
+
0,
|
|
62
|
+
"UPLOAD_FAILED"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
throw new AutopostingError(
|
|
67
|
+
`Failed to upload part: HTTP ${res.status} ${res.statusText}`,
|
|
68
|
+
res.status,
|
|
69
|
+
"UPLOAD_FAILED"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const etag = res.headers.get("etag");
|
|
73
|
+
if (!etag) {
|
|
74
|
+
throw new AutopostingError("Upload part response is missing the ETag header.", res.status, "UPLOAD_FAILED");
|
|
75
|
+
}
|
|
76
|
+
return etag;
|
|
77
|
+
}
|
|
78
|
+
var VERSION, AutopostingError, AuthenticationError, ScopeError, NotFoundError, ValidationError, RateLimitError, ServerError, Resource, MediaResource, AgentsResource, BillingResource, BrandsResource, CarouselsResource, MIN_PART_SIZE, DEFAULT_PART_SIZE, MAX_PARTS, VIDEO_MIME, ClipsResource, PostsResource, KbResource, IdeasResource, UsageResource, WebhooksResource, WorkspacesResource, IDEMPOTENT_METHODS, RETRYABLE_STATUS, sleep, Autoposting;
|
|
46
79
|
var init_dist = __esm({
|
|
47
80
|
"../sdk/dist/index.mjs"() {
|
|
48
81
|
"use strict";
|
|
49
|
-
VERSION = "0.3.
|
|
82
|
+
VERSION = "0.3.5";
|
|
50
83
|
AutopostingError = class extends Error {
|
|
51
84
|
status;
|
|
52
85
|
code;
|
|
@@ -180,6 +213,17 @@ var init_dist = __esm({
|
|
|
180
213
|
return this.client.request("DELETE", `/carousels/${id}`);
|
|
181
214
|
}
|
|
182
215
|
};
|
|
216
|
+
MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
217
|
+
DEFAULT_PART_SIZE = 16 * 1024 * 1024;
|
|
218
|
+
MAX_PARTS = 1e4;
|
|
219
|
+
VIDEO_MIME = {
|
|
220
|
+
mp4: "video/mp4",
|
|
221
|
+
mov: "video/quicktime",
|
|
222
|
+
webm: "video/webm",
|
|
223
|
+
mkv: "video/x-matroska",
|
|
224
|
+
avi: "video/x-msvideo",
|
|
225
|
+
m4v: "video/x-m4v"
|
|
226
|
+
};
|
|
183
227
|
ClipsResource = class extends Resource {
|
|
184
228
|
/** GET /clips — backend returns `{ clips, pagination }`, not a bare array. */
|
|
185
229
|
list() {
|
|
@@ -188,16 +232,66 @@ var init_dist = __esm({
|
|
|
188
232
|
retrieve(id) {
|
|
189
233
|
return this.client.request("GET", `/clips/${id}`);
|
|
190
234
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Upload a local video file as a clip by driving the backend multipart flow:
|
|
237
|
+
* `init → presign → PUT each part → complete`. On any failure after init, the
|
|
238
|
+
* multipart upload is aborted so it leaves no dangling clip. Returns the created clip.
|
|
239
|
+
*
|
|
240
|
+
* Node-only: reads the file in chunks (memory-bounded for multi-GB videos). `node:fs`
|
|
241
|
+
* is imported dynamically so the rest of the SDK stays runtime-agnostic.
|
|
242
|
+
*/
|
|
243
|
+
async upload(filePath, options) {
|
|
244
|
+
if (!options?.brandId) {
|
|
245
|
+
throw new AutopostingError("brandId is required to upload a clip.", 0, "VALIDATION_ERROR");
|
|
246
|
+
}
|
|
247
|
+
const { open: open2, stat } = await import("fs/promises");
|
|
248
|
+
const { basename } = await import("path");
|
|
249
|
+
const fileName = basename(filePath);
|
|
250
|
+
const title = options.title ?? fileName;
|
|
251
|
+
const mimeType = options.contentType ?? guessVideoMime(fileName);
|
|
252
|
+
const { size: fileSize } = await stat(filePath);
|
|
253
|
+
if (fileSize <= 0) {
|
|
254
|
+
throw new AutopostingError(`File is empty: ${filePath}`, 0, "VALIDATION_ERROR");
|
|
255
|
+
}
|
|
256
|
+
const partSize = resolvePartSize(fileSize, options.partSize);
|
|
257
|
+
const partNumbers = Array.from(
|
|
258
|
+
{ length: Math.max(1, Math.ceil(fileSize / partSize)) },
|
|
259
|
+
(_, i) => i + 1
|
|
200
260
|
);
|
|
261
|
+
const { clipId, uploadId, key } = await this.client.request("POST", "/clips/upload/init", { brandId: options.brandId, title, fileName, fileSize, mimeType });
|
|
262
|
+
let completed;
|
|
263
|
+
const handle = await open2(filePath, "r");
|
|
264
|
+
try {
|
|
265
|
+
const { presignedUrls } = await this.client.request("POST", "/clips/upload/presign", { clipId, uploadId, key, partNumbers });
|
|
266
|
+
const urlByPart = new Map(presignedUrls.map((p) => [p.partNumber, p.url]));
|
|
267
|
+
const parts = [];
|
|
268
|
+
let uploaded = 0;
|
|
269
|
+
for (const partNumber of partNumbers) {
|
|
270
|
+
const url = urlByPart.get(partNumber);
|
|
271
|
+
if (!url) {
|
|
272
|
+
throw new AutopostingError(`Missing presigned URL for part ${partNumber}.`, 0, "UPLOAD_FAILED");
|
|
273
|
+
}
|
|
274
|
+
const offset = (partNumber - 1) * partSize;
|
|
275
|
+
const length = Math.min(partSize, fileSize - offset);
|
|
276
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
277
|
+
await handle.read(buffer, 0, length, offset);
|
|
278
|
+
parts.push({ partNumber, etag: await putPart(url, buffer) });
|
|
279
|
+
uploaded += length;
|
|
280
|
+
options.onProgress?.(Math.round(uploaded / fileSize * 100));
|
|
281
|
+
}
|
|
282
|
+
completed = await this.client.request(
|
|
283
|
+
"POST",
|
|
284
|
+
"/clips/upload/complete",
|
|
285
|
+
{ clipId, uploadId, key, parts }
|
|
286
|
+
);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
await this.client.request("POST", "/clips/upload/abort", { clipId, uploadId, key }).catch(() => {
|
|
289
|
+
});
|
|
290
|
+
throw err;
|
|
291
|
+
} finally {
|
|
292
|
+
await handle.close();
|
|
293
|
+
}
|
|
294
|
+
return this.retrieve(completed.clipId);
|
|
201
295
|
}
|
|
202
296
|
/** POST /clips/import-url — backend returns `{ clipId }`, not a full Clip. */
|
|
203
297
|
importUrl(params) {
|
|
@@ -2950,6 +3044,17 @@ function resolveExitCode4(err) {
|
|
|
2950
3044
|
if (typeof attached === "number") return attached;
|
|
2951
3045
|
return exitCodeFromError(err);
|
|
2952
3046
|
}
|
|
3047
|
+
async function resolveBrandId(client, explicit) {
|
|
3048
|
+
if (explicit) return explicit;
|
|
3049
|
+
const brands = await client.brands.list();
|
|
3050
|
+
if (brands.length === 1) return brands[0].id;
|
|
3051
|
+
if (brands.length === 0) {
|
|
3052
|
+
throw new Error("No brands found. Create a brand first, then pass --brand <id>.");
|
|
3053
|
+
}
|
|
3054
|
+
throw new Error(
|
|
3055
|
+
`Multiple brands found \u2014 pass --brand <id>. Available: ${brands.map((b) => `${b.name} (${b.id})`).join(", ")}`
|
|
3056
|
+
);
|
|
3057
|
+
}
|
|
2953
3058
|
function createClipsCommand() {
|
|
2954
3059
|
const clips = new import_commander6.Command("clips").description("Manage video clips");
|
|
2955
3060
|
clips.command("list").description("List all clips").action(async (_opts, cmd) => {
|
|
@@ -2990,15 +3095,17 @@ function createClipsCommand() {
|
|
|
2990
3095
|
process.exit(resolveExitCode4(err));
|
|
2991
3096
|
}
|
|
2992
3097
|
});
|
|
2993
|
-
clips.command("upload <file>").description("Upload a video file as a clip").option("--name <name>", "Override the clip
|
|
3098
|
+
clips.command("upload <file>").description("Upload a video file as a clip").option("--brand <id>", "Brand to attach the clip to (defaults to your only brand)").option("--name <name>", "Override the clip title (defaults to filename)").action(async (file, opts, cmd) => {
|
|
2994
3099
|
const globals = cmd.optsWithGlobals();
|
|
2995
3100
|
const printer = createPrinter(globals);
|
|
2996
3101
|
const spinner = printer.spinner("Uploading clip\u2026");
|
|
2997
3102
|
try {
|
|
2998
3103
|
const cred = resolveAuth({ apiKey: globals.apiKey });
|
|
2999
3104
|
const client = new Autoposting({ apiKey: cred.apiKey });
|
|
3105
|
+
const brandId = await resolveBrandId(client, opts.brand);
|
|
3000
3106
|
const clip = await client.clips.upload(file, {
|
|
3001
|
-
|
|
3107
|
+
brandId,
|
|
3108
|
+
...opts.name ? { title: opts.name } : {},
|
|
3002
3109
|
onProgress: (pct) => {
|
|
3003
3110
|
if (pct > 0 && pct < 100) spinner.stop();
|
|
3004
3111
|
}
|