@adsuploader/cli 0.2.0 → 0.2.1

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
@@ -45,6 +45,7 @@ This package includes a `SKILL.md` file that describes every command and option
45
45
  | `ads presets` | List saved presets |
46
46
  | `ads presets:save --from-ad <id> --name <name>` | Save an existing ad as an API preset |
47
47
  | `ads upload <files...>` | Upload images and videos |
48
+ | `ads upload --retry-failed [batchId]` | Retry failed files into the same upload batch |
48
49
  | `ads uploads` | List recent upload batches |
49
50
  | `ads create spec.json` | Create ads from spec |
50
51
  | `ads create:preview spec.json` | Dry run |
package/SKILL.md CHANGED
@@ -41,6 +41,8 @@ Every ad creation follows three steps:
41
41
  ```bash
42
42
  ads upload hero.jpg banner.mp4
43
43
  ads upload ./my-creatives/ # entire directory
44
+ ads upload --retry-failed # retry latest failed batch
45
+ ads upload --retry-failed batch_abc123
44
46
  ```
45
47
 
46
48
  Returns a **batch ID** (e.g. `batch_abc123`) — you'll need this for the spec file. Files are uploaded directly to the selected ad account's Facebook media library, so the batch ID is tied to that account.
@@ -52,6 +54,7 @@ Files are staged in parallel and transient network failures are retried automati
52
54
  | `--concurrency <n>` | Parallel staging uploads, 1-6 (default: 4) |
53
55
  | `--upload-timeout <ms>` | Per-file R2 upload timeout (default: 120000) |
54
56
  | `--api-timeout <ms>` | API request timeout (default: 60000) |
57
+ | `--retry-failed [batchId]` | Retry failed files into the same batch; defaults to the latest saved failed batch |
55
58
 
56
59
  ### 2. Preview (dry run)
57
60
 
package/dist/cli.cjs CHANGED
@@ -1142,8 +1142,8 @@ var require_command = __commonJS({
1142
1142
  "node_modules/commander/lib/command.js"(exports2) {
1143
1143
  var EventEmitter = require("node:events").EventEmitter;
1144
1144
  var childProcess = require("node:child_process");
1145
- var path3 = require("node:path");
1146
- var fs4 = require("node:fs");
1145
+ var path4 = require("node:path");
1146
+ var fs5 = require("node:fs");
1147
1147
  var process2 = require("node:process");
1148
1148
  var { Argument: Argument2, humanReadableArgName } = require_argument();
1149
1149
  var { CommanderError: CommanderError2 } = require_error();
@@ -2124,7 +2124,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
2124
2124
  * @param {string} subcommandName
2125
2125
  */
2126
2126
  _checkForMissingExecutable(executableFile, executableDir, subcommandName) {
2127
- if (fs4.existsSync(executableFile)) return;
2127
+ if (fs5.existsSync(executableFile)) return;
2128
2128
  const executableDirMessage = executableDir ? `searched for local subcommand relative to directory '${executableDir}'` : "no directory for search for local subcommand, use .executableDir() to supply a custom directory";
2129
2129
  const executableMissing = `'${executableFile}' does not exist
2130
2130
  - if '${subcommandName}' is not meant to be an executable command, remove description parameter from '.command()' and use '.description()' instead
@@ -2142,11 +2142,11 @@ Expecting one of '${allowedValues.join("', '")}'`);
2142
2142
  let launchWithNode = false;
2143
2143
  const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"];
2144
2144
  function findFile(baseDir, baseName) {
2145
- const localBin = path3.resolve(baseDir, baseName);
2146
- if (fs4.existsSync(localBin)) return localBin;
2147
- if (sourceExt.includes(path3.extname(baseName))) return void 0;
2145
+ const localBin = path4.resolve(baseDir, baseName);
2146
+ if (fs5.existsSync(localBin)) return localBin;
2147
+ if (sourceExt.includes(path4.extname(baseName))) return void 0;
2148
2148
  const foundExt = sourceExt.find(
2149
- (ext) => fs4.existsSync(`${localBin}${ext}`)
2149
+ (ext) => fs5.existsSync(`${localBin}${ext}`)
2150
2150
  );
2151
2151
  if (foundExt) return `${localBin}${foundExt}`;
2152
2152
  return void 0;
@@ -2158,21 +2158,21 @@ Expecting one of '${allowedValues.join("', '")}'`);
2158
2158
  if (this._scriptPath) {
2159
2159
  let resolvedScriptPath;
2160
2160
  try {
2161
- resolvedScriptPath = fs4.realpathSync(this._scriptPath);
2161
+ resolvedScriptPath = fs5.realpathSync(this._scriptPath);
2162
2162
  } catch {
2163
2163
  resolvedScriptPath = this._scriptPath;
2164
2164
  }
2165
- executableDir = path3.resolve(
2166
- path3.dirname(resolvedScriptPath),
2165
+ executableDir = path4.resolve(
2166
+ path4.dirname(resolvedScriptPath),
2167
2167
  executableDir
2168
2168
  );
2169
2169
  }
2170
2170
  if (executableDir) {
2171
2171
  let localFile = findFile(executableDir, executableFile);
2172
2172
  if (!localFile && !subcommand._executableFile && this._scriptPath) {
2173
- const legacyName = path3.basename(
2173
+ const legacyName = path4.basename(
2174
2174
  this._scriptPath,
2175
- path3.extname(this._scriptPath)
2175
+ path4.extname(this._scriptPath)
2176
2176
  );
2177
2177
  if (legacyName !== this._name) {
2178
2178
  localFile = findFile(
@@ -2183,7 +2183,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
2183
2183
  }
2184
2184
  executableFile = localFile || executableFile;
2185
2185
  }
2186
- launchWithNode = sourceExt.includes(path3.extname(executableFile));
2186
+ launchWithNode = sourceExt.includes(path4.extname(executableFile));
2187
2187
  let proc;
2188
2188
  if (process2.platform !== "win32") {
2189
2189
  if (launchWithNode) {
@@ -3030,7 +3030,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
3030
3030
  * @return {Command}
3031
3031
  */
3032
3032
  nameFromFilename(filename) {
3033
- this._name = path3.basename(filename, path3.extname(filename));
3033
+ this._name = path4.basename(filename, path4.extname(filename));
3034
3034
  return this;
3035
3035
  }
3036
3036
  /**
@@ -3044,9 +3044,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
3044
3044
  * @param {string} [path]
3045
3045
  * @return {(string|null|Command)}
3046
3046
  */
3047
- executableDir(path4) {
3048
- if (path4 === void 0) return this._executableDir;
3049
- this._executableDir = path4;
3047
+ executableDir(path5) {
3048
+ if (path5 === void 0) return this._executableDir;
3049
+ this._executableDir = path5;
3050
3050
  return this;
3051
3051
  }
3052
3052
  /**
@@ -4186,8 +4186,8 @@ function createClient(opts = {}) {
4186
4186
  throw new Error(`Refusing to connect over insecure HTTP: ${baseUrl}. Use HTTPS or localhost.`);
4187
4187
  }
4188
4188
  }
4189
- async function request(method, path3, { body, query, requiresAccount = true, raw = false } = {}) {
4190
- const url = new URL(`/api/v1${path3}`, baseUrl);
4189
+ async function request(method, path4, { body, query, requiresAccount = true, raw = false } = {}) {
4190
+ const url = new URL(`/api/v1${path4}`, baseUrl);
4191
4191
  if (query) {
4192
4192
  for (const [k3, v2] of Object.entries(query)) {
4193
4193
  if (v2 != null) url.searchParams.set(k3, v2);
@@ -4250,9 +4250,9 @@ function createClient(opts = {}) {
4250
4250
  throw new Error(`API request failed after ${maxAttempts} attempt(s)`);
4251
4251
  }
4252
4252
  return {
4253
- get: (path3, opts2) => request("GET", path3, { ...opts2, requiresAccount: opts2?.requiresAccount ?? true }),
4254
- post: (path3, body, opts2) => request("POST", path3, { body, ...opts2 }),
4255
- del: (path3, body, opts2) => request("DELETE", path3, { body, ...opts2 }),
4253
+ get: (path4, opts2) => request("GET", path4, { ...opts2, requiresAccount: opts2?.requiresAccount ?? true }),
4254
+ post: (path4, body, opts2) => request("POST", path4, { body, ...opts2 }),
4255
+ del: (path4, body, opts2) => request("DELETE", path4, { body, ...opts2 }),
4256
4256
  accounts: {
4257
4257
  list: () => request("GET", "/accounts", { requiresAccount: false })
4258
4258
  },
@@ -4278,7 +4278,7 @@ function createClient(opts = {}) {
4278
4278
  },
4279
4279
  uploads: {
4280
4280
  list: (query) => request("GET", "/uploads", { query }),
4281
- init: (files) => request("POST", "/uploads/init", { body: { files } }),
4281
+ init: (files, { batchId } = {}) => request("POST", "/uploads/init", { body: { files, ...batchId ? { batchId } : {} } }),
4282
4282
  process: (batchId, file) => request("POST", `/uploads/${batchId}/process`, { body: file }),
4283
4283
  finalize: (batchId) => request("POST", `/uploads/${batchId}/finalize`, { body: {} }),
4284
4284
  status: (batchId) => request("GET", `/uploads/${batchId}`)
@@ -4353,6 +4353,9 @@ function printSuccess(msg) {
4353
4353
  function printError(msg) {
4354
4354
  console.error(` ${import_picocolors3.default.red("\u2716")} ${msg}`);
4355
4355
  }
4356
+ function printWarn(msg) {
4357
+ console.log(` ${import_picocolors3.default.yellow("\u26A0")} ${msg}`);
4358
+ }
4356
4359
  function printInfo(msg) {
4357
4360
  console.log(` ${import_picocolors3.default.blue("\u2139")} ${msg}`);
4358
4361
  }
@@ -4801,6 +4804,23 @@ function pagesCommand() {
4801
4804
 
4802
4805
  // src/commands/ad.js
4803
4806
  var import_picocolors8 = __toESM(require_picocolors(), 1);
4807
+
4808
+ // src/lib/profile-display.js
4809
+ var NOT_SET = "not set";
4810
+ function formatProfileValues(profile) {
4811
+ const pageId = profile?.page?.id || null;
4812
+ const pageName = profile?.page?.name || null;
4813
+ const instagramId = profile?.instagram?.id || null;
4814
+ const instagramUsername = profile?.instagram?.username || null;
4815
+ const threadsId = profile?.threads?.id || null;
4816
+ return {
4817
+ page: pageName && pageId ? `${pageName} (${pageId})` : pageId || NOT_SET,
4818
+ instagram: instagramUsername && instagramId ? `@${instagramUsername} (${instagramId})` : instagramId || NOT_SET,
4819
+ threads: threadsId || NOT_SET
4820
+ };
4821
+ }
4822
+
4823
+ // src/commands/ad.js
4804
4824
  function truncate(str, max = 120, expanded = false) {
4805
4825
  if (!str || expanded || str.length <= max) return str;
4806
4826
  return str.slice(0, max) + "...";
@@ -4820,6 +4840,15 @@ function adCommand() {
4820
4840
  console.log(` ${import_picocolors8.default.bold("Status:")} ${statusColor(ad.status)}`);
4821
4841
  console.log(` ${import_picocolors8.default.bold("Campaign:")} ${ad.campaignId}`);
4822
4842
  console.log(` ${import_picocolors8.default.bold("Ad Set:")} ${ad.adSetId}`);
4843
+ if (ad.profile) {
4844
+ const profile = formatProfileValues(ad.profile);
4845
+ const label = (name) => import_picocolors8.default.bold(name.padEnd(12));
4846
+ const display = (value) => value === "not set" ? import_picocolors8.default.dim(value) : value;
4847
+ console.log("");
4848
+ console.log(` ${label("Page:")}${display(profile.page)}`);
4849
+ console.log(` ${label("Instagram:")}${display(profile.instagram)}`);
4850
+ console.log(` ${label("Threads:")}${display(profile.threads)}`);
4851
+ }
4823
4852
  if (ad.creative) {
4824
4853
  const label = (name) => import_picocolors8.default.bold(name.padEnd(15));
4825
4854
  const exp = !!opts.expanded;
@@ -4975,9 +5004,70 @@ function presetsSaveCommand() {
4975
5004
  }
4976
5005
 
4977
5006
  // src/commands/upload.js
5007
+ var import_fs3 = __toESM(require("fs"), 1);
5008
+ var import_path3 = __toESM(require("path"), 1);
5009
+ var import_picocolors10 = __toESM(require_picocolors(), 1);
5010
+
5011
+ // src/lib/upload-state.js
4978
5012
  var import_fs2 = __toESM(require("fs"), 1);
4979
5013
  var import_path2 = __toESM(require("path"), 1);
4980
- var import_picocolors10 = __toESM(require_picocolors(), 1);
5014
+ var import_os2 = __toESM(require("os"), 1);
5015
+ var DEFAULT_CONFIG_DIR = import_path2.default.join(import_os2.default.homedir(), ".config", "adsuploader");
5016
+ function uploadsDir(baseDir = DEFAULT_CONFIG_DIR) {
5017
+ return import_path2.default.join(baseDir, "uploads");
5018
+ }
5019
+ function stateFile(batchId, baseDir = DEFAULT_CONFIG_DIR) {
5020
+ return import_path2.default.join(uploadsDir(baseDir), `${batchId}.json`);
5021
+ }
5022
+ function ensureUploadsDir(baseDir = DEFAULT_CONFIG_DIR) {
5023
+ import_fs2.default.mkdirSync(uploadsDir(baseDir), { recursive: true, mode: 448 });
5024
+ }
5025
+ function saveFailedBatch({ batchId, accountId, failedFiles }, { baseDir } = {}) {
5026
+ ensureUploadsDir(baseDir);
5027
+ const data = {
5028
+ batchId,
5029
+ accountId: accountId || null,
5030
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5031
+ failedFiles: failedFiles.map((file) => ({
5032
+ path: file.path,
5033
+ name: file.name,
5034
+ type: file.type,
5035
+ size: file.size,
5036
+ contentType: file.contentType
5037
+ }))
5038
+ };
5039
+ import_fs2.default.writeFileSync(stateFile(batchId, baseDir), JSON.stringify(data, null, 2), { mode: 384 });
5040
+ if (process.platform !== "win32") {
5041
+ import_fs2.default.chmodSync(stateFile(batchId, baseDir), 384);
5042
+ }
5043
+ }
5044
+ function loadFailedBatch(batchId, { baseDir } = {}) {
5045
+ const filePath = stateFile(batchId, baseDir);
5046
+ if (!import_fs2.default.existsSync(filePath)) return null;
5047
+ return JSON.parse(import_fs2.default.readFileSync(filePath, "utf8"));
5048
+ }
5049
+ function findLatestFailedBatch({ baseDir } = {}) {
5050
+ const dir = uploadsDir(baseDir);
5051
+ if (!import_fs2.default.existsSync(dir)) return null;
5052
+ const batches = import_fs2.default.readdirSync(dir).filter((name) => name.endsWith(".json")).map((name) => {
5053
+ try {
5054
+ const data = JSON.parse(import_fs2.default.readFileSync(import_path2.default.join(dir, name), "utf8"));
5055
+ return data?.batchId && data?.createdAt ? data : null;
5056
+ } catch {
5057
+ return null;
5058
+ }
5059
+ }).filter(Boolean).sort((a, b4) => new Date(b4.createdAt) - new Date(a.createdAt));
5060
+ return batches[0] || null;
5061
+ }
5062
+ function clearFailedBatch(batchId, { baseDir } = {}) {
5063
+ try {
5064
+ import_fs2.default.unlinkSync(stateFile(batchId, baseDir));
5065
+ } catch (err) {
5066
+ if (err.code !== "ENOENT") throw err;
5067
+ }
5068
+ }
5069
+
5070
+ // src/commands/upload.js
4981
5071
  var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]);
4982
5072
  var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"]);
4983
5073
  var MAX_FILE_SIZE = 4 * 1024 * 1024 * 1024;
@@ -5001,19 +5091,19 @@ var MAGIC_BYTES = {
5001
5091
  // ftyp container — check below
5002
5092
  };
5003
5093
  function validateMagicBytes(filePath, contentType) {
5004
- const stat = import_fs2.default.statSync(filePath);
5094
+ const stat = import_fs3.default.statSync(filePath);
5005
5095
  if (stat.size < 12) return false;
5006
5096
  let fd;
5007
5097
  try {
5008
- fd = import_fs2.default.openSync(filePath, "r");
5098
+ fd = import_fs3.default.openSync(filePath, "r");
5009
5099
  const buf = Buffer.alloc(12);
5010
- import_fs2.default.readSync(fd, buf, 0, 12, 0);
5011
- import_fs2.default.closeSync(fd);
5100
+ import_fs3.default.readSync(fd, buf, 0, 12, 0);
5101
+ import_fs3.default.closeSync(fd);
5012
5102
  fd = null;
5013
5103
  return checkMagicBytes(buf, contentType);
5014
5104
  } finally {
5015
5105
  if (fd != null) try {
5016
- import_fs2.default.closeSync(fd);
5106
+ import_fs3.default.closeSync(fd);
5017
5107
  } catch {
5018
5108
  }
5019
5109
  }
@@ -5118,8 +5208,38 @@ async function putR2WithRetry(uploadUrl, fileBuffer, contentType, {
5118
5208
  }
5119
5209
  throw new Error(`R2 upload failed after ${maxAttempts} attempt(s)`);
5120
5210
  }
5211
+ function isRetryableProcessError(err) {
5212
+ const status = Number.isFinite(err?.status) ? err.status : null;
5213
+ if (status >= 400 && status < 500) return false;
5214
+ if (isRetryableNetworkError(err)) return true;
5215
+ if (status >= 500) return true;
5216
+ const message = String(err?.message || "");
5217
+ return /is not valid JSON|Unexpected token|<!DOCTYPE/i.test(message) || message.includes("Facebook image upload failed");
5218
+ }
5219
+ async function processFileWithRetry(client, batchId, file, {
5220
+ sleep: delay = sleep2,
5221
+ maxAttempts = 4
5222
+ } = {}) {
5223
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
5224
+ try {
5225
+ return await client.uploads.process(batchId, {
5226
+ name: file.name,
5227
+ type: file.type,
5228
+ size: file.size,
5229
+ fileKey: file.fileKey
5230
+ });
5231
+ } catch (err) {
5232
+ if (attempt < maxAttempts && isRetryableProcessError(err)) {
5233
+ await delay(getRetryDelayMs(attempt));
5234
+ continue;
5235
+ }
5236
+ throw err;
5237
+ }
5238
+ }
5239
+ throw new Error(`Upload processing failed after ${maxAttempts} attempt(s)`);
5240
+ }
5121
5241
  function getFileType(filePath) {
5122
- const ext = import_path2.default.extname(filePath).toLowerCase();
5242
+ const ext = import_path3.default.extname(filePath).toLowerCase();
5123
5243
  if (IMAGE_EXTS.has(ext)) return "image";
5124
5244
  if (VIDEO_EXTS.has(ext)) return "video";
5125
5245
  return null;
@@ -5128,22 +5248,22 @@ function resolveFiles(inputs) {
5128
5248
  const files = [];
5129
5249
  const skipped = [];
5130
5250
  for (const input of inputs) {
5131
- const resolved = import_path2.default.resolve(input);
5132
- if (!import_fs2.default.existsSync(resolved)) {
5251
+ const resolved = import_path3.default.resolve(input);
5252
+ if (!import_fs3.default.existsSync(resolved)) {
5133
5253
  throw new Error(`File not found: ${input}`);
5134
5254
  }
5135
- const stat = import_fs2.default.statSync(resolved);
5255
+ const stat = import_fs3.default.statSync(resolved);
5136
5256
  if (stat.isDirectory()) {
5137
- const entries = import_fs2.default.readdirSync(resolved);
5257
+ const entries = import_fs3.default.readdirSync(resolved);
5138
5258
  for (const entry of entries) {
5139
- const fullPath = import_path2.default.join(resolved, entry);
5140
- const entryStat = import_fs2.default.statSync(fullPath);
5259
+ const fullPath = import_path3.default.join(resolved, entry);
5260
+ const entryStat = import_fs3.default.statSync(fullPath);
5141
5261
  if (entryStat.isFile() && getFileType(fullPath)) {
5142
5262
  if (entryStat.size > MAX_FILE_SIZE || entryStat.size === 0) {
5143
5263
  skipped.push(`${entry} (${entryStat.size === 0 ? "empty" : "too large"})`);
5144
5264
  continue;
5145
5265
  }
5146
- const ct = CONTENT_TYPES[import_path2.default.extname(fullPath).toLowerCase()];
5266
+ const ct = CONTENT_TYPES[import_path3.default.extname(fullPath).toLowerCase()];
5147
5267
  if (!validateMagicBytes(fullPath, ct)) {
5148
5268
  skipped.push(`${entry} (content does not match extension)`);
5149
5269
  continue;
@@ -5156,9 +5276,9 @@ function resolveFiles(inputs) {
5156
5276
  if (!type) throw new Error(`Unsupported file type: ${input}`);
5157
5277
  if (stat.size > MAX_FILE_SIZE) throw new Error(`File too large: ${input} (${formatSize(stat.size)}, max ${formatSize(MAX_FILE_SIZE)})`);
5158
5278
  if (stat.size === 0) throw new Error(`File is empty: ${input}`);
5159
- const ct = CONTENT_TYPES[import_path2.default.extname(resolved).toLowerCase()];
5279
+ const ct = CONTENT_TYPES[import_path3.default.extname(resolved).toLowerCase()];
5160
5280
  if (!validateMagicBytes(resolved, ct)) throw new Error(`File content does not match its extension: ${input}`);
5161
- files.push({ path: resolved, name: import_path2.default.basename(resolved), size: stat.size, type, contentType: ct });
5281
+ files.push({ path: resolved, name: import_path3.default.basename(resolved), size: stat.size, type, contentType: ct });
5162
5282
  }
5163
5283
  }
5164
5284
  if (skipped.length > 0) {
@@ -5166,7 +5286,43 @@ function resolveFiles(inputs) {
5166
5286
  }
5167
5287
  return files;
5168
5288
  }
5169
- async function stageFiles(paths, opts) {
5289
+ function resolveRetryFiles(failedFiles) {
5290
+ const files = [];
5291
+ const skipped = [];
5292
+ for (const file of failedFiles) {
5293
+ if (!file.path || !import_fs3.default.existsSync(file.path)) {
5294
+ skipped.push(`${file.name || import_path3.default.basename(file.path || "unknown")} (missing)`);
5295
+ continue;
5296
+ }
5297
+ const stat = import_fs3.default.statSync(file.path);
5298
+ if (!stat.isFile()) {
5299
+ skipped.push(`${file.name || import_path3.default.basename(file.path)} (not a file)`);
5300
+ continue;
5301
+ }
5302
+ const type = getFileType(file.path) || file.type;
5303
+ if (!type) {
5304
+ skipped.push(`${file.name || import_path3.default.basename(file.path)} (unsupported)`);
5305
+ continue;
5306
+ }
5307
+ const contentType = file.contentType || CONTENT_TYPES[import_path3.default.extname(file.path).toLowerCase()];
5308
+ if (contentType && !validateMagicBytes(file.path, contentType)) {
5309
+ skipped.push(`${file.name || import_path3.default.basename(file.path)} (content does not match extension)`);
5310
+ continue;
5311
+ }
5312
+ files.push({
5313
+ path: file.path,
5314
+ name: file.name || import_path3.default.basename(file.path),
5315
+ size: stat.size,
5316
+ type,
5317
+ contentType
5318
+ });
5319
+ }
5320
+ if (skipped.length > 0) {
5321
+ console.error(import_picocolors10.default.yellow(` Skipped ${skipped.length} file(s): ${skipped.join(", ")}`));
5322
+ }
5323
+ return files;
5324
+ }
5325
+ async function stageFiles(paths, opts, { batchId: existingBatchId, files: filesOverride } = {}) {
5170
5326
  const client = createClient({
5171
5327
  accountId: opts.account,
5172
5328
  timeoutMs: parsePositiveInt2(opts.apiTimeout, void 0)
@@ -5175,7 +5331,7 @@ async function stageFiles(paths, opts) {
5175
5331
  const uploadTimeoutMs = parsePositiveInt2(opts.uploadTimeout, DEFAULT_UPLOAD_TIMEOUT_MS);
5176
5332
  let files;
5177
5333
  try {
5178
- files = resolveFiles(paths);
5334
+ files = filesOverride || resolveFiles(paths);
5179
5335
  } catch (err) {
5180
5336
  printError(err.message);
5181
5337
  process.exit(1);
@@ -5184,27 +5340,27 @@ async function stageFiles(paths, opts) {
5184
5340
  printError("No supported media files found.");
5185
5341
  process.exit(1);
5186
5342
  }
5187
- if (!jsonMode) {
5188
- console.log(`
5189
- ${import_picocolors10.default.bold("Uploading")} ${files.length} file(s)
5190
- `);
5191
- }
5192
5343
  const filesMeta = files.map((f) => ({
5193
5344
  name: f.name,
5194
5345
  size: f.size,
5195
5346
  type: f.type,
5196
5347
  contentType: f.contentType
5197
5348
  }));
5198
- const initResult = await client.uploads.init(filesMeta);
5349
+ const initResult = await client.uploads.init(filesMeta, { batchId: existingBatchId });
5199
5350
  const { batchId } = initResult;
5200
5351
  const concurrency = calculateUploadConcurrency(files, opts.concurrency);
5352
+ if (!jsonMode) {
5353
+ console.log(`
5354
+ ${import_picocolors10.default.bold("Uploading")} ${files.length} file(s) ${import_picocolors10.default.dim(`(staging concurrency: ${concurrency})`)}
5355
+ `);
5356
+ }
5201
5357
  const uploadedFiles = await runBoundedConcurrency(files, concurrency, async (file, i) => {
5202
5358
  const r2Info = initResult.files[i];
5203
5359
  if (!jsonMode) {
5204
5360
  console.log(` ${import_picocolors10.default.dim("\u2192")} ${import_picocolors10.default.dim(`Uploading ${file.name}... ${formatSize(file.size)}`)}`);
5205
5361
  }
5206
5362
  try {
5207
- const fileBuffer = import_fs2.default.readFileSync(file.path);
5363
+ const fileBuffer = import_fs3.default.readFileSync(file.path);
5208
5364
  await putR2WithRetry(
5209
5365
  r2Info.uploadUrl,
5210
5366
  fileBuffer,
@@ -5212,40 +5368,60 @@ async function stageFiles(paths, opts) {
5212
5368
  { timeoutMs: uploadTimeoutMs }
5213
5369
  );
5214
5370
  return {
5371
+ path: file.path,
5215
5372
  name: file.name,
5216
5373
  type: file.type,
5217
5374
  size: file.size,
5375
+ contentType: file.contentType,
5218
5376
  fileKey: r2Info.fileKey
5219
5377
  };
5220
5378
  } catch (err) {
5221
5379
  if (!jsonMode) {
5222
5380
  console.log(` ${import_picocolors10.default.red("\u2717")} ${file.name}: ${err.message}`);
5223
5381
  }
5224
- return { name: file.name, type: file.type, fileKey: null, error: err.message };
5382
+ return {
5383
+ path: file.path,
5384
+ name: file.name,
5385
+ type: file.type,
5386
+ size: file.size,
5387
+ contentType: file.contentType,
5388
+ fileKey: null,
5389
+ error: err.message
5390
+ };
5225
5391
  }
5226
5392
  });
5227
- return { client, batchId, uploadedFiles, jsonMode };
5393
+ return { client, batchId, uploadedFiles, files, jsonMode };
5228
5394
  }
5229
- async function processAndPrint(client, batchId, uploadedFiles, jsonMode) {
5395
+ async function processAndPrint(client, batchId, uploadedFiles, jsonMode, { isRetry = false } = {}) {
5230
5396
  const validFiles = uploadedFiles.filter((f) => f.fileKey);
5231
5397
  if (validFiles.length === 0) {
5232
5398
  printError("All uploads failed.");
5233
- process.exit(1);
5399
+ return {
5400
+ batchId,
5401
+ status: "failed",
5402
+ completed: 0,
5403
+ failed: uploadedFiles.length,
5404
+ total: 0,
5405
+ files: [],
5406
+ groups: [],
5407
+ failedUploadedFiles: uploadedFiles
5408
+ };
5234
5409
  }
5235
5410
  if (!jsonMode) {
5236
- console.log(` ${import_picocolors10.default.dim("\u2192")} ${import_picocolors10.default.dim("Finalizing uploads to Facebook...")}`);
5411
+ console.log(` ${import_picocolors10.default.dim("\u2192")} ${import_picocolors10.default.dim(`Finalizing ${validFiles.length} upload(s) to Facebook...`)}`);
5412
+ if (validFiles.some((file) => file.type === "video")) {
5413
+ console.log(` ${import_picocolors10.default.dim("Note: video finalization waits on Meta-side processing, so longer waits are expected.")}`);
5414
+ }
5237
5415
  }
5238
5416
  const results = [];
5239
5417
  let completed = 0;
5240
5418
  let failed = 0;
5241
- for (const file of validFiles) {
5419
+ for (const [index, file] of validFiles.entries()) {
5242
5420
  try {
5243
- const result = await client.uploads.process(batchId, {
5244
- name: file.name,
5245
- type: file.type,
5246
- size: file.size,
5247
- fileKey: file.fileKey
5248
- });
5421
+ if (!jsonMode) {
5422
+ console.log(` ${import_picocolors10.default.dim("\u2192")} ${import_picocolors10.default.dim(`Finalizing ${index + 1} of ${validFiles.length}: ${file.name}...`)}`);
5423
+ }
5424
+ const result = await processFileWithRetry(client, batchId, file);
5249
5425
  results.push(result);
5250
5426
  if (result.status === "complete") {
5251
5427
  completed++;
@@ -5268,7 +5444,7 @@ async function processAndPrint(client, batchId, uploadedFiles, jsonMode) {
5268
5444
  }
5269
5445
  }
5270
5446
  let groups = [];
5271
- if (completed > 1) {
5447
+ if (isRetry ? completed >= 1 : completed > 1) {
5272
5448
  try {
5273
5449
  const finalizeResult = await client.uploads.finalize(batchId);
5274
5450
  groups = finalizeResult.groups || [];
@@ -5277,7 +5453,16 @@ async function processAndPrint(client, batchId, uploadedFiles, jsonMode) {
5277
5453
  }
5278
5454
  if (jsonMode) {
5279
5455
  printJson({ batchId, status: failed === 0 ? "complete" : completed === 0 ? "failed" : "partial", completed, failed, total: validFiles.length, files: results, groups });
5280
- return;
5456
+ return {
5457
+ batchId,
5458
+ status: failed === 0 ? "complete" : completed === 0 ? "failed" : "partial",
5459
+ completed,
5460
+ failed,
5461
+ total: validFiles.length,
5462
+ files: results,
5463
+ groups,
5464
+ failedUploadedFiles: uploadedFiles.filter((file) => !file.fileKey || results.some((result) => result.name === file.name && result.status === "error"))
5465
+ };
5281
5466
  }
5282
5467
  if (groups.length > 0) {
5283
5468
  console.log(`
@@ -5302,11 +5487,93 @@ async function processAndPrint(client, batchId, uploadedFiles, jsonMode) {
5302
5487
  console.log(` ${import_picocolors10.default.bold("Groups:")} ${groups.length} variant group(s) detected`);
5303
5488
  }
5304
5489
  console.log("");
5490
+ return {
5491
+ batchId,
5492
+ status: failed === 0 ? "complete" : completed === 0 ? "failed" : "partial",
5493
+ completed,
5494
+ failed,
5495
+ total: validFiles.length,
5496
+ files: results,
5497
+ groups,
5498
+ failedUploadedFiles: uploadedFiles.filter((file) => !file.fileKey || results.some((result) => result.name === file.name && result.status === "error"))
5499
+ };
5500
+ }
5501
+ function failedFileState(uploadedFiles, summary) {
5502
+ const failedProcessNames = new Set((summary.files || []).filter((result) => result.status === "error").map((result) => result.name));
5503
+ return uploadedFiles.filter((file) => !file.fileKey || failedProcessNames.has(file.name)).map((file) => ({
5504
+ path: file.path,
5505
+ name: file.name,
5506
+ type: file.type,
5507
+ size: file.size,
5508
+ contentType: file.contentType
5509
+ }));
5510
+ }
5511
+ function updateFailedBatchState({ batchId, accountId, uploadedFiles, summary }) {
5512
+ const failedFiles = failedFileState(uploadedFiles, summary);
5513
+ if (failedFiles.length > 0) {
5514
+ saveFailedBatch({ batchId, accountId, failedFiles });
5515
+ } else {
5516
+ clearFailedBatch(batchId);
5517
+ }
5518
+ return failedFiles;
5519
+ }
5520
+ async function runUploadFlow(paths, opts, { batchId, files, isRetry = false } = {}) {
5521
+ const { client, batchId: resolvedBatchId, uploadedFiles, jsonMode } = await stageFiles(paths, opts, { batchId, files });
5522
+ const summary = await processAndPrint(client, resolvedBatchId, uploadedFiles, jsonMode, { isRetry });
5523
+ const failedFiles = updateFailedBatchState({
5524
+ batchId: resolvedBatchId,
5525
+ accountId: client.accountId,
5526
+ uploadedFiles,
5527
+ summary
5528
+ });
5529
+ if (failedFiles.length > 0 && summary.completed === 0) {
5530
+ process.exit(1);
5531
+ }
5532
+ }
5533
+ function resolveRetryAccount(savedAccountId, requestedAccountId) {
5534
+ if (requestedAccountId) {
5535
+ if (savedAccountId && requestedAccountId !== savedAccountId) {
5536
+ printWarn(
5537
+ `--account ${requestedAccountId} differs from the failed batch's account ${savedAccountId}; retrying under a different account is not supported and can mix accounts in one batch.`
5538
+ );
5539
+ }
5540
+ return requestedAccountId;
5541
+ }
5542
+ return savedAccountId || void 0;
5543
+ }
5544
+ async function runRetryFailed(opts) {
5545
+ const requestedBatchId = typeof opts.retryFailed === "string" ? opts.retryFailed : null;
5546
+ const failedBatch = requestedBatchId ? loadFailedBatch(requestedBatchId) : findLatestFailedBatch();
5547
+ if (!failedBatch) {
5548
+ printError("No failed upload batch found to retry");
5549
+ process.exit(1);
5550
+ }
5551
+ const files = resolveRetryFiles(failedBatch.failedFiles || []);
5552
+ if (files.length === 0) {
5553
+ printError("No retryable files found in failed upload batch.");
5554
+ clearFailedBatch(failedBatch.batchId);
5555
+ process.exit(1);
5556
+ }
5557
+ const account = resolveRetryAccount(failedBatch.accountId, opts.account);
5558
+ await runUploadFlow([], { ...opts, account }, { batchId: failedBatch.batchId, files, isRetry: true });
5305
5559
  }
5306
5560
  function uploadCommand() {
5307
- return new Command("upload").description("Upload media files and process to Meta").argument("<paths...>", "Files or directories to upload").option("--account <id>", "Ad account ID").option("--json", "Output as JSON").option("--concurrency <n>", "Concurrent R2 staging uploads (1-6)", String(DEFAULT_UPLOAD_CONCURRENCY)).option("--upload-timeout <ms>", "R2 PUT timeout in milliseconds", String(DEFAULT_UPLOAD_TIMEOUT_MS)).option("--api-timeout <ms>", "API request timeout in milliseconds").action(async (paths, opts) => {
5308
- const { client, batchId, uploadedFiles, jsonMode } = await stageFiles(paths, opts);
5309
- await processAndPrint(client, batchId, uploadedFiles, jsonMode);
5561
+ return new Command("upload").description("Upload media files and process to Meta").argument("[paths...]", "Files or directories to upload").option("--account <id>", "Ad account ID").option("--json", "Output as JSON").option("--concurrency <n>", "Concurrent R2 staging uploads (1-6)", String(DEFAULT_UPLOAD_CONCURRENCY)).option("--upload-timeout <ms>", "R2 PUT timeout in milliseconds", String(DEFAULT_UPLOAD_TIMEOUT_MS)).option("--api-timeout <ms>", "API request timeout in milliseconds").option("--retry-failed [batchId]", "Retry the latest failed upload batch or the specified batch ID").action(async (paths, opts) => {
5562
+ const hasRetryFailed = opts.retryFailed != null;
5563
+ const hasPaths = Array.isArray(paths) && paths.length > 0;
5564
+ if (hasRetryFailed && hasPaths) {
5565
+ printError("Do not pass file paths with --retry-failed; the failed files are loaded from saved state.");
5566
+ process.exit(1);
5567
+ }
5568
+ if (hasRetryFailed) {
5569
+ await runRetryFailed(opts);
5570
+ return;
5571
+ }
5572
+ if (!hasPaths) {
5573
+ printError("Missing required argument: paths");
5574
+ process.exit(1);
5575
+ }
5576
+ await runUploadFlow(paths, opts);
5310
5577
  });
5311
5578
  }
5312
5579
  function uploadsCommand() {
@@ -5390,7 +5657,7 @@ function uploadsCommand() {
5390
5657
  }
5391
5658
 
5392
5659
  // src/commands/create.js
5393
- var import_fs3 = __toESM(require("fs"), 1);
5660
+ var import_fs4 = __toESM(require("fs"), 1);
5394
5661
  var import_picocolors12 = __toESM(require_picocolors(), 1);
5395
5662
 
5396
5663
  // src/lib/poll.js
@@ -5629,6 +5896,13 @@ async function executeCreate(body, opts, { preview = false, test = false } = {})
5629
5896
  if (sharedCta) console.log(` ${import_picocolors12.default.bold("CTA:")} ${sharedCta}`);
5630
5897
  if (sharedLink) console.log(` ${import_picocolors12.default.bold("Link:")} ${wrapLine(sharedLink, valCol)}`);
5631
5898
  if (sharedUrlTags) console.log(` ${import_picocolors12.default.bold("URL Tags:")} ${wrapLine(sharedUrlTags, valCol)}`);
5899
+ if (result.profile) {
5900
+ const profile = formatProfileValues(result.profile);
5901
+ const display = (value) => value === "not set" ? import_picocolors12.default.dim(value) : value;
5902
+ console.log(` ${import_picocolors12.default.bold("Page:")} ${wrapLine(display(profile.page), valCol)}`);
5903
+ console.log(` ${import_picocolors12.default.bold("Instagram:")} ${wrapLine(display(profile.instagram), valCol)}`);
5904
+ console.log(` ${import_picocolors12.default.bold("Threads:")} ${wrapLine(display(profile.threads), valCol)}`);
5905
+ }
5632
5906
  const enh = result.enhancements;
5633
5907
  let enhDetail;
5634
5908
  if (enh === "metaDefaults") enhDetail = "Meta Defaults";
@@ -5702,7 +5976,7 @@ function buildBodyFromOpts(specFile, opts) {
5702
5976
  let body;
5703
5977
  if (specFile) {
5704
5978
  try {
5705
- const raw = import_fs3.default.readFileSync(specFile, "utf8");
5979
+ const raw = import_fs4.default.readFileSync(specFile, "utf8");
5706
5980
  body = JSON.parse(raw);
5707
5981
  } catch (err) {
5708
5982
  printError(`Failed to read spec file: ${err.message}`);
@@ -5716,7 +5990,7 @@ function buildBodyFromOpts(specFile, opts) {
5716
5990
  if (opts.upload) body.uploadId = opts.upload;
5717
5991
  if (opts.textFile) {
5718
5992
  try {
5719
- body.texts = JSON.parse(import_fs3.default.readFileSync(opts.textFile, "utf8"));
5993
+ body.texts = JSON.parse(import_fs4.default.readFileSync(opts.textFile, "utf8"));
5720
5994
  } catch (err) {
5721
5995
  printError(`Failed to read text file: ${err.message}`);
5722
5996
  process.exit(1);
@@ -6177,7 +6451,7 @@ function statusLabel(status) {
6177
6451
  }
6178
6452
 
6179
6453
  // src/cli.js
6180
- var VERSION = true ? "0.2.0" : "0.0.0";
6454
+ var VERSION = true ? "0.2.1" : "0.0.0";
6181
6455
  var apiUrl = process.env.ADS_API_URL || getBaseUrl();
6182
6456
  if (apiUrl && (apiUrl.includes("localhost") || apiUrl.includes("127.0.0.1"))) {
6183
6457
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
@@ -6219,12 +6493,12 @@ program2.addCommand(createTestCommand(), { hidden: true });
6219
6493
  program2.addCommand(jobsCommand());
6220
6494
  async function checkForUpdates() {
6221
6495
  try {
6222
- const fs4 = await import("fs");
6223
- const path3 = await import("path");
6224
- const os2 = await import("os");
6225
- const cacheFile = path3.join(os2.default.homedir(), ".config", "adsuploader", ".update-check");
6496
+ const fs5 = await import("fs");
6497
+ const path4 = await import("path");
6498
+ const os3 = await import("os");
6499
+ const cacheFile = path4.join(os3.default.homedir(), ".config", "adsuploader", ".update-check");
6226
6500
  try {
6227
- const stat = fs4.default.statSync(cacheFile);
6501
+ const stat = fs5.default.statSync(cacheFile);
6228
6502
  if (Date.now() - stat.mtimeMs < 24 * 60 * 60 * 1e3) return;
6229
6503
  } catch {
6230
6504
  }
@@ -6233,8 +6507,8 @@ async function checkForUpdates() {
6233
6507
  const data = await resp.json();
6234
6508
  const latest = data.version;
6235
6509
  try {
6236
- fs4.default.mkdirSync(path3.dirname(cacheFile), { recursive: true });
6237
- fs4.default.writeFileSync(cacheFile, latest);
6510
+ fs5.default.mkdirSync(path4.dirname(cacheFile), { recursive: true });
6511
+ fs5.default.writeFileSync(cacheFile, latest);
6238
6512
  } catch {
6239
6513
  }
6240
6514
  if (latest !== VERSION) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adsuploader/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Create Facebook ads from the command line — bulk upload media, preview configurations, and launch ads at scale",
5
5
  "author": "Ads Uploader <support@adsuploader.com> (https://adsuploader.com)",
6
6
  "license": "UNLICENSED",