@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 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
- var VERSION, AutopostingError, AuthenticationError, ScopeError, NotFoundError, ValidationError, RateLimitError, ServerError, Resource, MediaResource, AgentsResource, BillingResource, BrandsResource, CarouselsResource, ClipsResource, PostsResource, KbResource, IdeasResource, UsageResource, WebhooksResource, WorkspacesResource, IDEMPOTENT_METHODS, RETRYABLE_STATUS, sleep, Autoposting;
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.4";
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
- // ponytail: the backend has no single-request /clips/upload; uploading a file means the
192
- // multipart init/presign/complete flow (upload-initiate → upload-presign → upload-complete).
193
- // That's too much surface for v0.3.1 — fail honestly and point at importUrl. Upgrade path:
194
- // implement the init/presign/complete dance here when direct upload is actually needed.
195
- async upload(_filePath, _options) {
196
- throw new AutopostingError(
197
- "Direct clip file upload is not supported by the API yet \u2014 use `importUrl` with a public video URL instead.",
198
- 0,
199
- "NOT_IMPLEMENTED"
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 name (defaults to filename)").action(async (file, opts, cmd) => {
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
- name: opts.name,
3107
+ brandId,
3108
+ ...opts.name ? { title: opts.name } : {},
3002
3109
  onProgress: (pct) => {
3003
3110
  if (pct > 0 && pct < 100) spinner.stop();
3004
3111
  }