@mrgoonie/multix 0.14.0 → 0.14.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/CHANGELOG.md +7 -0
- package/README.md +4 -1
- package/dist/cli.js +337 -78
- package/package.json +1 -1
- package/skill/SKILL.md +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.14.1](https://github.com/mrgoonie/multix-cli/compare/v0.14.0...v0.14.1) (2026-10-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
* **replicate:** harden output downloads, --prefer-wait and create errors ([#88](https://github.com/mrgoonie/multix-cli/issues/88)) ([e29e81d](https://github.com/mrgoonie/multix-cli/commit/e29e81d7b3e54e5b65f4905c1adb629f2d4df6c4))
|
|
14
|
+
|
|
8
15
|
## [0.14.0](https://github.com/mrgoonie/multix-cli/compare/v0.13.0...v0.14.0) (2026-10-11)
|
|
9
16
|
|
|
10
17
|
|
package/README.md
CHANGED
|
@@ -98,6 +98,7 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
|
|
|
98
98
|
| `CLOUDFLARE_AI_IMAGE_MODEL` | No | Image model setting; only `@cf/black-forest-labs/flux-1-schnell` is accepted |
|
|
99
99
|
| `CLOUDFLARE_AI_TTS_MODEL` | No | Speech model setting; only `@cf/myshell-ai/melotts` is accepted |
|
|
100
100
|
| `MULTIX_OUTPUT_DIR` | No | Override default output dir (`./multix-output`) |
|
|
101
|
+
| `MULTIX_MAX_DOWNLOAD_MB` | No | Size limit per downloaded Replicate output file (default `2048`) |
|
|
101
102
|
| `MULTIX_IMAGE_FORMAT` | No | `webp` (default) or `original` to keep provider image format/quality |
|
|
102
103
|
| `OPENROUTER_IMAGE_MODEL` | No | Default OpenRouter model |
|
|
103
104
|
| `OPENROUTER_FALLBACK_MODELS` | No | Comma-separated fallback model ids |
|
|
@@ -556,9 +557,11 @@ multix replicate cancel <prediction-id>
|
|
|
556
557
|
|
|
557
558
|
**Notes:**
|
|
558
559
|
- Auth: `Authorization: Bearer $REPLICATE_API_TOKEN` (`REPLICATE_API_KEY` accepted as fallback).
|
|
559
|
-
- Default behavior waits with bounded polling; `--async` prints the prediction id plus resume/cancel commands. `--prefer-wait` sends `Prefer: wait=N` and keeps polling if the job is still running.
|
|
560
|
+
- Default behavior waits with bounded polling; `--async` prints the prediction id plus resume/cancel commands. `--prefer-wait [seconds]` (an integer from 1 to 60; the bare flag means 60) sends `Prefer: wait=N` and keeps polling if the job is still running. Put the prompt before a bare `--prefer-wait`; any other value is rejected.
|
|
560
561
|
- Only `succeeded` exits 0; `failed`, `canceled`, and `aborted` exit non-zero with Replicate's error.
|
|
562
|
+
- If submitting fails with a network error, timeout, 5xx, or an unreadable 2xx response, the prediction may still have been created and billed (Replicate has no idempotency key). Check https://replicate.com/predictions before retrying.
|
|
561
563
|
- Every media URL in the output (string, list, or nested object) is downloaded as `replicate-<id8>-NN.<ext>` (`<id8>` = first 8 characters of the prediction id); text outputs are printed. Output URLs expire, so download promptly. If any download fails, the command exits non-zero and prints `multix replicate status <id> --download` to retry.
|
|
564
|
+
- Output downloads use `https://` only and refuse `localhost` and loopback, private, link-local, or other special-purpose IP literals (hostnames are not resolved), including on redirects. Files are written to `<path>.part` and renamed when complete, so a failed download never clobbers an existing file. Hosts other than `replicate.delivery` are downloaded with a warning that names the host. Each file is capped at `MULTIX_MAX_DOWNLOAD_MB` (default 2048).
|
|
562
565
|
- `replicate image` converts images to WebP by default; `--image-format original`, `--no-webp`, `MULTIX_IMAGE_FORMAT=original`, an explicit `--output-format`, or a non-`.webp` `--output` extension keeps the provider format.
|
|
563
566
|
- Local files (`--image`, `--file key=path`) up to 256 KB are inlined as data URIs; larger files (< 100 MB) are uploaded via the Replicate Files API.
|
|
564
567
|
|
package/dist/cli.js
CHANGED
|
@@ -1120,40 +1120,168 @@ import fs8 from "fs";
|
|
|
1120
1120
|
import path7 from "path";
|
|
1121
1121
|
|
|
1122
1122
|
// src/core/http-client.ts
|
|
1123
|
-
init_errors();
|
|
1124
|
-
import { once } from "events";
|
|
1125
1123
|
import fs3 from "fs";
|
|
1126
1124
|
import path3 from "path";
|
|
1125
|
+
import { Readable } from "stream";
|
|
1127
1126
|
import { pipeline } from "stream/promises";
|
|
1127
|
+
init_errors();
|
|
1128
|
+
init_redact();
|
|
1128
1129
|
async function httpJson(opts) {
|
|
1129
1130
|
return fetchJson(opts);
|
|
1130
1131
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1132
|
+
var MAX_DOWNLOAD_ENV = "MULTIX_MAX_DOWNLOAD_MB";
|
|
1133
|
+
var DEFAULT_MAX_DOWNLOAD_MB = 2048;
|
|
1134
|
+
var MAX_REDIRECTS = 5;
|
|
1135
|
+
function formatMb2(bytes) {
|
|
1136
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1137
|
+
}
|
|
1138
|
+
var DownloadTooLargeError = class extends MultixError {
|
|
1139
|
+
constructor(limitBytes, url) {
|
|
1140
|
+
super(
|
|
1141
|
+
`Download from ${redactUrl(url)} exceeds the ${formatMb2(limitBytes)} limit (set ${MAX_DOWNLOAD_ENV} to change it)`,
|
|
1142
|
+
"DOWNLOAD_TOO_LARGE"
|
|
1143
|
+
);
|
|
1144
|
+
this.limitBytes = limitBytes;
|
|
1145
|
+
this.name = "DownloadTooLargeError";
|
|
1146
|
+
}
|
|
1147
|
+
limitBytes;
|
|
1148
|
+
};
|
|
1149
|
+
function maxDownloadBytes() {
|
|
1150
|
+
const raw = resolveKey(MAX_DOWNLOAD_ENV)?.trim();
|
|
1151
|
+
if (!raw) return DEFAULT_MAX_DOWNLOAD_MB * 1024 * 1024;
|
|
1152
|
+
const mb = Number(raw);
|
|
1153
|
+
if (!Number.isFinite(mb) || mb <= 0) {
|
|
1154
|
+
throw new ConfigError(
|
|
1155
|
+
`${MAX_DOWNLOAD_ENV} must be a positive number of megabytes, got "${raw}"`
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
const bytes = Math.floor(mb * 1024 * 1024);
|
|
1159
|
+
if (bytes < 1) {
|
|
1160
|
+
throw new ConfigError(`${MAX_DOWNLOAD_ENV} is too small: "${raw}" MB is less than one byte`);
|
|
1161
|
+
}
|
|
1162
|
+
return bytes;
|
|
1163
|
+
}
|
|
1164
|
+
async function fetchWithValidatedRedirects(url, opts = {}) {
|
|
1165
|
+
const doFetch = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1166
|
+
const { validateUrl, signal } = opts;
|
|
1167
|
+
const maxRedirects = opts.maxRedirects ?? MAX_REDIRECTS;
|
|
1168
|
+
let current = url;
|
|
1169
|
+
let headers2 = opts.headers;
|
|
1170
|
+
for (let hop = 0; ; hop++) {
|
|
1171
|
+
await validateUrl?.(current);
|
|
1172
|
+
let response;
|
|
1173
|
+
try {
|
|
1174
|
+
response = await doFetch(current, {
|
|
1175
|
+
...signal ? { signal } : {},
|
|
1176
|
+
...headers2 ? { headers: headers2 } : {},
|
|
1177
|
+
...validateUrl ? { redirect: "manual" } : {}
|
|
1178
|
+
});
|
|
1179
|
+
} catch (cause) {
|
|
1180
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
1181
|
+
throw new HttpError(0, msg, current);
|
|
1182
|
+
}
|
|
1183
|
+
const location = response.headers.get("location");
|
|
1184
|
+
if (!validateUrl || !location || response.status < 300 || response.status >= 400) {
|
|
1185
|
+
return { response, url: current };
|
|
1186
|
+
}
|
|
1187
|
+
await response.body?.cancel().catch(() => {
|
|
1138
1188
|
});
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1189
|
+
if (hop >= maxRedirects) {
|
|
1190
|
+
throw new HttpError(response.status, `too many redirects (over ${maxRedirects})`, current);
|
|
1191
|
+
}
|
|
1192
|
+
let next;
|
|
1193
|
+
try {
|
|
1194
|
+
next = new URL(location, current);
|
|
1195
|
+
} catch {
|
|
1196
|
+
throw new HttpError(response.status, "redirect with a malformed Location header", current);
|
|
1197
|
+
}
|
|
1198
|
+
if (next.origin !== new URL(current).origin) headers2 = void 0;
|
|
1199
|
+
current = next.toString();
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
function removeQuietly(file) {
|
|
1203
|
+
try {
|
|
1204
|
+
fs3.rmSync(file, { force: true });
|
|
1205
|
+
} catch {
|
|
1142
1206
|
}
|
|
1207
|
+
}
|
|
1208
|
+
function replaceFile(from, to) {
|
|
1209
|
+
try {
|
|
1210
|
+
fs3.renameSync(from, to);
|
|
1211
|
+
} catch (err2) {
|
|
1212
|
+
const code = err2.code;
|
|
1213
|
+
const existing = fs3.statSync(to, { throwIfNoEntry: false });
|
|
1214
|
+
if (!existing?.isFile() || code !== "EEXIST" && code !== "EPERM" && code !== "EACCES") {
|
|
1215
|
+
throw err2;
|
|
1216
|
+
}
|
|
1217
|
+
fs3.rmSync(to, { force: true });
|
|
1218
|
+
fs3.renameSync(from, to);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
async function downloadToFile(url, opts) {
|
|
1222
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
1223
|
+
const { response, url: finalUrl } = await fetchWithValidatedRedirects(url, {
|
|
1224
|
+
signal,
|
|
1225
|
+
headers: opts.headers,
|
|
1226
|
+
validateUrl: opts.validateUrl,
|
|
1227
|
+
fetch: opts.fetch
|
|
1228
|
+
});
|
|
1143
1229
|
if (!response.ok) {
|
|
1144
|
-
|
|
1230
|
+
await response.body?.cancel().catch(() => {
|
|
1231
|
+
});
|
|
1232
|
+
throw new HttpError(response.status, "(download failed)", finalUrl);
|
|
1145
1233
|
}
|
|
1146
1234
|
if (!response.body) {
|
|
1147
|
-
throw new HttpError(0, "Response body is null",
|
|
1235
|
+
throw new HttpError(0, "Response body is null", finalUrl);
|
|
1236
|
+
}
|
|
1237
|
+
const body = response.body;
|
|
1238
|
+
const { maxBytes } = opts;
|
|
1239
|
+
const declared = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
1240
|
+
if (maxBytes !== void 0 && Number.isFinite(declared) && declared > maxBytes) {
|
|
1241
|
+
await body.cancel().catch(() => {
|
|
1242
|
+
});
|
|
1243
|
+
throw new DownloadTooLargeError(maxBytes, finalUrl);
|
|
1148
1244
|
}
|
|
1149
|
-
|
|
1245
|
+
let dest;
|
|
1150
1246
|
try {
|
|
1151
|
-
|
|
1247
|
+
dest = typeof opts.dest === "function" ? opts.dest(response) : opts.dest;
|
|
1248
|
+
fs3.mkdirSync(path3.dirname(dest), { recursive: true });
|
|
1152
1249
|
} catch (err2) {
|
|
1153
|
-
|
|
1250
|
+
await body.cancel().catch(() => {
|
|
1251
|
+
});
|
|
1154
1252
|
throw err2;
|
|
1155
1253
|
}
|
|
1156
|
-
|
|
1254
|
+
let bytes = 0;
|
|
1255
|
+
async function* countBytes(source) {
|
|
1256
|
+
for await (const chunk of source) {
|
|
1257
|
+
bytes += chunk.byteLength;
|
|
1258
|
+
if (maxBytes !== void 0 && bytes > maxBytes)
|
|
1259
|
+
throw new DownloadTooLargeError(maxBytes, finalUrl);
|
|
1260
|
+
yield chunk;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const part = `${dest}.part`;
|
|
1264
|
+
let fileStream;
|
|
1265
|
+
try {
|
|
1266
|
+
fileStream = fs3.createWriteStream(part);
|
|
1267
|
+
await pipeline(
|
|
1268
|
+
Readable.fromWeb(body),
|
|
1269
|
+
countBytes,
|
|
1270
|
+
fileStream
|
|
1271
|
+
);
|
|
1272
|
+
replaceFile(part, dest);
|
|
1273
|
+
} catch (err2) {
|
|
1274
|
+
if (fileStream && !fileStream.closed) {
|
|
1275
|
+
await new Promise((resolve) => fileStream?.once("close", () => resolve()));
|
|
1276
|
+
}
|
|
1277
|
+
removeQuietly(part);
|
|
1278
|
+
throw err2;
|
|
1279
|
+
}
|
|
1280
|
+
return { dest, contentType: response.headers.get("content-type") ?? void 0, bytes };
|
|
1281
|
+
}
|
|
1282
|
+
async function downloadFile(url, dest, timeoutMs = DEFAULT_TIMEOUT_MS, headers2) {
|
|
1283
|
+
const result = await downloadToFile(url, { dest, timeoutMs, headers: headers2 });
|
|
1284
|
+
return result.contentType;
|
|
1157
1285
|
}
|
|
1158
1286
|
async function fetchBytes(urlOrDataUri) {
|
|
1159
1287
|
if (urlOrDataUri.startsWith("data:")) {
|
|
@@ -6080,7 +6208,7 @@ var HEYGEN_OPERATIONS = {
|
|
|
6080
6208
|
};
|
|
6081
6209
|
|
|
6082
6210
|
// src/providers/heygen/node-helpers.ts
|
|
6083
|
-
import { once
|
|
6211
|
+
import { once } from "events";
|
|
6084
6212
|
import fs29 from "fs";
|
|
6085
6213
|
import path32 from "path";
|
|
6086
6214
|
import { pipeline as pipeline2 } from "stream/promises";
|
|
@@ -7087,7 +7215,7 @@ function inferExtension(url, fallback) {
|
|
|
7087
7215
|
function safeSlug(value) {
|
|
7088
7216
|
return value.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "out";
|
|
7089
7217
|
}
|
|
7090
|
-
var
|
|
7218
|
+
var MAX_REDIRECTS2 = 5;
|
|
7091
7219
|
var isRedirect = (status) => [301, 302, 303, 307, 308].includes(status);
|
|
7092
7220
|
async function downloadHttpsOnly(url, dest, timeoutMs) {
|
|
7093
7221
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
@@ -7109,7 +7237,7 @@ async function downloadHttpsOnly(url, dest, timeoutMs) {
|
|
|
7109
7237
|
const location = response.headers.get("location");
|
|
7110
7238
|
if (isRedirect(response.status) && location) {
|
|
7111
7239
|
await response.body?.cancel();
|
|
7112
|
-
if (hop >=
|
|
7240
|
+
if (hop >= MAX_REDIRECTS2) throw new HttpError(0, "too many redirects", url);
|
|
7113
7241
|
current = new URL(location, current).href;
|
|
7114
7242
|
continue;
|
|
7115
7243
|
}
|
|
@@ -7120,7 +7248,7 @@ async function downloadHttpsOnly(url, dest, timeoutMs) {
|
|
|
7120
7248
|
try {
|
|
7121
7249
|
await pipeline2(response.body, fileStream);
|
|
7122
7250
|
} catch (err2) {
|
|
7123
|
-
if (!fileStream.closed) await
|
|
7251
|
+
if (!fileStream.closed) await once(fileStream, "close");
|
|
7124
7252
|
throw err2;
|
|
7125
7253
|
}
|
|
7126
7254
|
return;
|
|
@@ -10225,8 +10353,8 @@ async function downloadOpenRouterVideo(opts) {
|
|
|
10225
10353
|
}
|
|
10226
10354
|
fs43.mkdirSync(path50.dirname(dest), { recursive: true });
|
|
10227
10355
|
const ws = fs43.createWriteStream(dest);
|
|
10228
|
-
const { pipeline:
|
|
10229
|
-
await
|
|
10356
|
+
const { pipeline: pipeline3 } = await import("stream/promises");
|
|
10357
|
+
await pipeline3(r.body, ws);
|
|
10230
10358
|
logger.success(`Saved ${dest}`);
|
|
10231
10359
|
if (opts.output) {
|
|
10232
10360
|
fs43.mkdirSync(path50.dirname(path50.resolve(opts.output)), { recursive: true });
|
|
@@ -10537,6 +10665,19 @@ function mapReplicateError(error, subject) {
|
|
|
10537
10665
|
}
|
|
10538
10666
|
return new ReplicateApiError(message, status, retryAfterMs);
|
|
10539
10667
|
}
|
|
10668
|
+
var ORPHAN_HINT = "The prediction may have been created anyway (Replicate has no idempotency key); check https://replicate.com/predictions before retrying to avoid paying twice";
|
|
10669
|
+
function withOrphanHint(error) {
|
|
10670
|
+
if (error instanceof ReplicateApiError) {
|
|
10671
|
+
const { status } = error;
|
|
10672
|
+
const mayExist = status === 0 || status >= 200 && status < 300 || status >= 500;
|
|
10673
|
+
if (!mayExist) return error;
|
|
10674
|
+
return new ReplicateApiError(`${error.message}. ${ORPHAN_HINT}`, status, error.retryAfterMs);
|
|
10675
|
+
}
|
|
10676
|
+
if (error instanceof ProviderError) {
|
|
10677
|
+
return new ProviderError(`${error.message}. ${ORPHAN_HINT}`, error.provider);
|
|
10678
|
+
}
|
|
10679
|
+
return error;
|
|
10680
|
+
}
|
|
10540
10681
|
function validatePrediction(value) {
|
|
10541
10682
|
if (value === null || typeof value !== "object" || typeof value.id !== "string" || typeof value.status !== "string") {
|
|
10542
10683
|
throw new ProviderError("Malformed Replicate response: missing prediction id/status", PROVIDER);
|
|
@@ -10605,7 +10746,7 @@ var ReplicateClient = class {
|
|
|
10605
10746
|
await this.sleep(delay);
|
|
10606
10747
|
continue;
|
|
10607
10748
|
}
|
|
10608
|
-
throw mapReplicateError(error, subject);
|
|
10749
|
+
throw withOrphanHint(mapReplicateError(error, subject));
|
|
10609
10750
|
}
|
|
10610
10751
|
}
|
|
10611
10752
|
}
|
|
@@ -10752,23 +10893,137 @@ init_errors();
|
|
|
10752
10893
|
|
|
10753
10894
|
// src/providers/replicate/commands/prediction-flow.ts
|
|
10754
10895
|
init_errors();
|
|
10896
|
+
import { InvalidArgumentError, Option as Option2 } from "commander";
|
|
10755
10897
|
|
|
10756
10898
|
// src/providers/replicate/node-outputs.ts
|
|
10757
10899
|
init_errors();
|
|
10758
|
-
init_redact();
|
|
10759
|
-
import { once as once3 } from "events";
|
|
10760
10900
|
import fs44 from "fs";
|
|
10761
10901
|
import path51 from "path";
|
|
10762
|
-
|
|
10763
|
-
import { pipeline as pipeline3 } from "stream/promises";
|
|
10902
|
+
init_redact();
|
|
10764
10903
|
|
|
10765
10904
|
// src/providers/replicate/output.ts
|
|
10766
10905
|
init_redact();
|
|
10906
|
+
|
|
10907
|
+
// src/core/url-safety.ts
|
|
10908
|
+
init_errors();
|
|
10909
|
+
function ipv4Octets(host) {
|
|
10910
|
+
const parts = host.split(".");
|
|
10911
|
+
if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p))) return void 0;
|
|
10912
|
+
const octets = parts.map(Number);
|
|
10913
|
+
return octets.every((o) => o <= 255) ? octets : void 0;
|
|
10914
|
+
}
|
|
10915
|
+
function isNonPublicIpv4([a = 0, b = 0, c2 = 0]) {
|
|
10916
|
+
return a === 0 || // "this network" / unspecified
|
|
10917
|
+
a === 10 || a === 127 || // loopback
|
|
10918
|
+
a === 100 && b >= 64 && b <= 127 || // carrier-grade NAT
|
|
10919
|
+
a === 169 && b === 254 || // link-local (cloud metadata)
|
|
10920
|
+
a === 172 && b >= 16 && b <= 31 || a === 192 && b === 0 && c2 === 0 || // IETF protocol assignments
|
|
10921
|
+
a === 192 && b === 0 && c2 === 2 || // TEST-NET-1
|
|
10922
|
+
a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || // benchmarking
|
|
10923
|
+
a === 198 && b === 51 && c2 === 100 || // TEST-NET-2
|
|
10924
|
+
a === 203 && b === 0 && c2 === 113 || // TEST-NET-3
|
|
10925
|
+
a >= 224;
|
|
10926
|
+
}
|
|
10927
|
+
function ipv6Groups(host) {
|
|
10928
|
+
if (!/^[0-9a-f:.]+$/i.test(host) || !host.includes(":")) return void 0;
|
|
10929
|
+
let text = host;
|
|
10930
|
+
const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
10931
|
+
if (tail) {
|
|
10932
|
+
const octets = ipv4Octets(tail[2]);
|
|
10933
|
+
if (!octets) return void 0;
|
|
10934
|
+
const [a = 0, b = 0, c2 = 0, d = 0] = octets;
|
|
10935
|
+
text = `${tail[1]}${(a << 8 | b).toString(16)}:${(c2 << 8 | d).toString(16)}`;
|
|
10936
|
+
}
|
|
10937
|
+
const halves = text.split("::");
|
|
10938
|
+
if (halves.length > 2) return void 0;
|
|
10939
|
+
const parse = (s) => s === "" ? [] : s.split(":");
|
|
10940
|
+
const head = parse(halves[0]);
|
|
10941
|
+
const rest = halves.length === 2 ? parse(halves[1]) : [];
|
|
10942
|
+
const missing = 8 - head.length - rest.length;
|
|
10943
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0) return void 0;
|
|
10944
|
+
const all = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest];
|
|
10945
|
+
if (!all.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return void 0;
|
|
10946
|
+
return all.map((g) => Number.parseInt(g, 16));
|
|
10947
|
+
}
|
|
10948
|
+
function embeddedIpv4(hi, lo) {
|
|
10949
|
+
return [hi >> 8, hi & 255, lo >> 8, lo & 255];
|
|
10950
|
+
}
|
|
10951
|
+
function isNonPublicIpv6(groups) {
|
|
10952
|
+
const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = groups;
|
|
10953
|
+
const zeroUpTo = (n) => groups.slice(0, n).every((g) => g === 0);
|
|
10954
|
+
if (zeroUpTo(5) && g5 === 65535) return isNonPublicIpv4(embeddedIpv4(g6, g7));
|
|
10955
|
+
if (g0 === 100 && g1 === 65435 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) {
|
|
10956
|
+
return isNonPublicIpv4(embeddedIpv4(g6, g7));
|
|
10957
|
+
}
|
|
10958
|
+
if (g0 === 8194) return isNonPublicIpv4(embeddedIpv4(g1, g2));
|
|
10959
|
+
return g0 === 0 || // unspecified (::), loopback (::1), IPv4-compatible and other reserved
|
|
10960
|
+
g0 === 256 && g1 === 0 && g2 === 0 && g3 === 0 || // discard-only 100::/64
|
|
10961
|
+
(g0 & 65024) === 64512 || // unique local fc00::/7
|
|
10962
|
+
(g0 & 65472) === 65152 || // link-local fe80::/10
|
|
10963
|
+
(g0 & 65472) === 65216 || // deprecated site-local fec0::/10
|
|
10964
|
+
(g0 & 65280) === 65280;
|
|
10965
|
+
}
|
|
10966
|
+
function bareHost(hostname) {
|
|
10967
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
10968
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
10969
|
+
}
|
|
10970
|
+
function isLoopbackHost(hostname) {
|
|
10971
|
+
const host = bareHost(hostname);
|
|
10972
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
10973
|
+
const octets = ipv4Octets(host);
|
|
10974
|
+
if (octets) return octets[0] === 127;
|
|
10975
|
+
const groups = ipv6Groups(host);
|
|
10976
|
+
if (!groups) return false;
|
|
10977
|
+
if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 65535) {
|
|
10978
|
+
return groups[6] !== void 0 && groups[6] >> 8 === 127;
|
|
10979
|
+
}
|
|
10980
|
+
return groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1;
|
|
10981
|
+
}
|
|
10982
|
+
function isNonPublicHost(hostname) {
|
|
10983
|
+
const host = bareHost(hostname);
|
|
10984
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
10985
|
+
const octets = ipv4Octets(host);
|
|
10986
|
+
if (octets) return isNonPublicIpv4(octets);
|
|
10987
|
+
const groups = ipv6Groups(host);
|
|
10988
|
+
return groups !== void 0 && isNonPublicIpv6(groups);
|
|
10989
|
+
}
|
|
10990
|
+
function checkUrlSafety(url, opts) {
|
|
10991
|
+
const action = opts.action ?? "download from";
|
|
10992
|
+
let parsed;
|
|
10993
|
+
try {
|
|
10994
|
+
parsed = new URL(url);
|
|
10995
|
+
} catch {
|
|
10996
|
+
throw new ProviderError(`Refusing to ${action} a malformed URL`, opts.provider);
|
|
10997
|
+
}
|
|
10998
|
+
const host = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
|
10999
|
+
const localOk = opts.allowLocalhostHttp === true && isLoopbackHost(host);
|
|
11000
|
+
const httpOk = parsed.protocol === "https:" || localOk && parsed.protocol === "http:";
|
|
11001
|
+
if (!httpOk) {
|
|
11002
|
+
throw new ProviderError(
|
|
11003
|
+
`Refusing to ${action} ${host || "this URL"}: only https:// URLs are allowed`,
|
|
11004
|
+
opts.provider
|
|
11005
|
+
);
|
|
11006
|
+
}
|
|
11007
|
+
if (!localOk && isNonPublicHost(host)) {
|
|
11008
|
+
throw new ProviderError(
|
|
11009
|
+
`Refusing to ${action} ${host}: it points to a local or private address`,
|
|
11010
|
+
opts.provider
|
|
11011
|
+
);
|
|
11012
|
+
}
|
|
11013
|
+
const trusted = (opts.trustedHosts ?? []).some((t) => host === t || host.endsWith(`.${t}`));
|
|
11014
|
+
return { host, trusted };
|
|
11015
|
+
}
|
|
11016
|
+
|
|
11017
|
+
// src/providers/replicate/output.ts
|
|
10767
11018
|
var HTTP_RE = /^https?:\/\//i;
|
|
10768
11019
|
var DATA_URI_RE = /^data:[^,]*,/i;
|
|
10769
11020
|
function isMediaRef(value) {
|
|
10770
11021
|
return typeof value === "string" && (HTTP_RE.test(value) || DATA_URI_RE.test(value));
|
|
10771
11022
|
}
|
|
11023
|
+
var REPLICATE_DELIVERY_HOST = "replicate.delivery";
|
|
11024
|
+
function checkOutputUrl(url) {
|
|
11025
|
+
return checkUrlSafety(url, { provider: "replicate", trustedHosts: [REPLICATE_DELIVERY_HOST] });
|
|
11026
|
+
}
|
|
10772
11027
|
function extractOutput(output) {
|
|
10773
11028
|
const result = { media: [], texts: [] };
|
|
10774
11029
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -10927,59 +11182,53 @@ function resolveDestination(predictionId, index, count, ext, opts) {
|
|
|
10927
11182
|
const base = givenExt ? output.slice(0, -givenExt.length) : output;
|
|
10928
11183
|
return `${base}-${String(index + 1).padStart(2, "0")}${ext}`;
|
|
10929
11184
|
}
|
|
10930
|
-
async function writeResponse(response, dest) {
|
|
10931
|
-
if (!response.body) throw new HttpError(0, "Response body is null", response.url);
|
|
10932
|
-
const fileStream = fs44.createWriteStream(dest);
|
|
10933
|
-
try {
|
|
10934
|
-
await pipeline3(
|
|
10935
|
-
Readable.fromWeb(response.body),
|
|
10936
|
-
fileStream
|
|
10937
|
-
);
|
|
10938
|
-
} catch (err2) {
|
|
10939
|
-
if (!fileStream.closed) await once3(fileStream, "close");
|
|
10940
|
-
throw err2;
|
|
10941
|
-
}
|
|
10942
|
-
}
|
|
10943
11185
|
async function saveOne(item, predictionId, index, count, opts) {
|
|
10944
11186
|
if (item.url.startsWith("data:")) {
|
|
10945
11187
|
const comma = item.url.indexOf(",");
|
|
10946
11188
|
const meta = item.url.slice(5, comma);
|
|
10947
11189
|
const payload = item.url.slice(comma + 1);
|
|
10948
11190
|
const bytes = meta.endsWith(";base64") ? Buffer.from(payload, "base64") : Buffer.from(decodeURIComponent(payload));
|
|
10949
|
-
const
|
|
11191
|
+
const dest = resolveDestination(
|
|
10950
11192
|
predictionId,
|
|
10951
11193
|
index,
|
|
10952
11194
|
count,
|
|
10953
11195
|
extFromUrl4(item.url) ?? ".bin",
|
|
10954
11196
|
opts
|
|
10955
11197
|
);
|
|
10956
|
-
fs44.mkdirSync(path51.dirname(
|
|
10957
|
-
fs44.writeFileSync(
|
|
10958
|
-
return
|
|
10959
|
-
}
|
|
10960
|
-
const doFetch = opts.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
10961
|
-
let response;
|
|
10962
|
-
try {
|
|
10963
|
-
response = await doFetch(item.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
10964
|
-
} catch (cause) {
|
|
10965
|
-
throw new HttpError(0, cause instanceof Error ? cause.message : String(cause), item.url);
|
|
10966
|
-
}
|
|
10967
|
-
if (!response.ok) {
|
|
10968
|
-
const hint = response.status === 404 || response.status === 410 ? " (output may have expired)" : "";
|
|
10969
|
-
throw new HttpError(response.status, `download failed${hint}`, item.url);
|
|
11198
|
+
fs44.mkdirSync(path51.dirname(dest), { recursive: true });
|
|
11199
|
+
fs44.writeFileSync(dest, bytes);
|
|
11200
|
+
return dest;
|
|
10970
11201
|
}
|
|
10971
|
-
const
|
|
10972
|
-
const dest = resolveDestination(predictionId, index, count, ext, opts);
|
|
10973
|
-
fs44.mkdirSync(path51.dirname(dest), { recursive: true });
|
|
11202
|
+
const warned = opts.warnedHosts;
|
|
10974
11203
|
try {
|
|
10975
|
-
await
|
|
11204
|
+
const { dest } = await downloadToFile(item.url, {
|
|
11205
|
+
dest: (response) => {
|
|
11206
|
+
const ext = extFromUrl4(item.url) ?? extFromContentType(response.headers.get("content-type")) ?? ".bin";
|
|
11207
|
+
return resolveDestination(predictionId, index, count, ext, opts);
|
|
11208
|
+
},
|
|
11209
|
+
timeoutMs: DOWNLOAD_TIMEOUT_MS,
|
|
11210
|
+
maxBytes: opts.maxBytes,
|
|
11211
|
+
fetch: opts.fetch,
|
|
11212
|
+
validateUrl: (url) => {
|
|
11213
|
+
const { host, trusted } = checkOutputUrl(url);
|
|
11214
|
+
if (!trusted && !warned.has(host)) {
|
|
11215
|
+
warned.add(host);
|
|
11216
|
+
opts.logger.warn(
|
|
11217
|
+
`Output ${item.path} is served from ${host}, not ${REPLICATE_DELIVERY_HOST}`
|
|
11218
|
+
);
|
|
11219
|
+
}
|
|
11220
|
+
}
|
|
11221
|
+
});
|
|
11222
|
+
return dest;
|
|
10976
11223
|
} catch (err2) {
|
|
10977
|
-
|
|
11224
|
+
if (err2 instanceof HttpError && (err2.status === 404 || err2.status === 410)) {
|
|
11225
|
+
throw new HttpError(err2.status, "download failed (output may have expired)", err2.rawUrl);
|
|
11226
|
+
}
|
|
10978
11227
|
throw err2;
|
|
10979
11228
|
}
|
|
10980
|
-
return dest;
|
|
10981
11229
|
}
|
|
10982
11230
|
async function saveMediaOutputs(predictionId, media, opts) {
|
|
11231
|
+
const withCap = { ...opts, maxBytes: maxDownloadBytes(), warnedHosts: /* @__PURE__ */ new Set() };
|
|
10983
11232
|
const saved = [];
|
|
10984
11233
|
const failed = [];
|
|
10985
11234
|
for (let i = 0; i < media.length; i++) {
|
|
@@ -10987,7 +11236,7 @@ async function saveMediaOutputs(predictionId, media, opts) {
|
|
|
10987
11236
|
if (!item) continue;
|
|
10988
11237
|
opts.logger.debug(`Downloading ${item.path} from ${redactUrl(item.url).slice(0, 200)}`);
|
|
10989
11238
|
try {
|
|
10990
|
-
const dest = await saveOne(item, predictionId, i, media.length,
|
|
11239
|
+
const dest = await saveOne(item, predictionId, i, media.length, withCap);
|
|
10991
11240
|
saved.push(dest);
|
|
10992
11241
|
opts.logger.success(`Saved ${dest}`);
|
|
10993
11242
|
} catch (e) {
|
|
@@ -11004,10 +11253,22 @@ async function saveMediaOutputs(predictionId, media, opts) {
|
|
|
11004
11253
|
}
|
|
11005
11254
|
|
|
11006
11255
|
// src/providers/replicate/commands/prediction-flow.ts
|
|
11256
|
+
var DEFAULT_PREFER_WAIT_SECONDS = 60;
|
|
11257
|
+
function parsePreferWaitSeconds(raw) {
|
|
11258
|
+
const n = Number(raw);
|
|
11259
|
+
if (!/^\d+$/.test(raw.trim()) || n < 1 || n > 60) {
|
|
11260
|
+
throw new InvalidArgumentError(
|
|
11261
|
+
`Expected an integer number of seconds from 1 to 60 (a bare --prefer-wait means ${DEFAULT_PREFER_WAIT_SECONDS}); put the prompt before the flag.`
|
|
11262
|
+
);
|
|
11263
|
+
}
|
|
11264
|
+
return n;
|
|
11265
|
+
}
|
|
11007
11266
|
function addFlowOptions(cmd, defaultTimeoutMs) {
|
|
11008
|
-
return cmd.option("--async", "Submit and return immediately with the prediction id").option("--wait", "Wait for a terminal status (default unless --async)").
|
|
11009
|
-
|
|
11010
|
-
|
|
11267
|
+
return cmd.option("--async", "Submit and return immediately with the prediction id").option("--wait", "Wait for a terminal status (default unless --async)").addOption(
|
|
11268
|
+
new Option2(
|
|
11269
|
+
"--prefer-wait [seconds]",
|
|
11270
|
+
"Ask Replicate to hold the create request open for 1-60s (bare flag: 60); keeps polling if still running"
|
|
11271
|
+
).argParser(parsePreferWaitSeconds).preset(String(DEFAULT_PREFER_WAIT_SECONDS))
|
|
11011
11272
|
).option("--wait-timeout <ms>", "Polling timeout in milliseconds", String(defaultTimeoutMs)).option("--download", "Download media outputs (default)").option("--no-download", "Print output URLs instead of downloading").option("--output <path>", "Output file path, or a directory for multiple outputs").option("-v, --verbose", "Verbose logging");
|
|
11012
11273
|
}
|
|
11013
11274
|
function parsePositiveInt2(raw, flag, max) {
|
|
@@ -11019,11 +11280,6 @@ function parsePositiveInt2(raw, flag, max) {
|
|
|
11019
11280
|
}
|
|
11020
11281
|
return n;
|
|
11021
11282
|
}
|
|
11022
|
-
function parsePreferWait(value) {
|
|
11023
|
-
if (value === void 0 || value === false) return void 0;
|
|
11024
|
-
if (value === true) return 60;
|
|
11025
|
-
return parsePositiveInt2(value, "--prefer-wait", 60);
|
|
11026
|
-
}
|
|
11027
11283
|
function resumeCommand3(id) {
|
|
11028
11284
|
return `multix replicate status ${id} --wait --download`;
|
|
11029
11285
|
}
|
|
@@ -11078,13 +11334,16 @@ async function executePrediction2(params) {
|
|
|
11078
11334
|
const { client, model, input, opts, logger } = params;
|
|
11079
11335
|
if (opts.async && opts.wait)
|
|
11080
11336
|
throw new ValidationError("--async and --wait are mutually exclusive");
|
|
11081
|
-
if (opts.async && opts.preferWait) {
|
|
11337
|
+
if (opts.async && opts.preferWait !== void 0) {
|
|
11082
11338
|
throw new ValidationError("--async and --prefer-wait are mutually exclusive");
|
|
11083
11339
|
}
|
|
11084
11340
|
const waitTimeoutMs = parsePositiveInt2(opts.waitTimeout, "--wait-timeout");
|
|
11085
|
-
const preferWaitSeconds = parsePreferWait(opts.preferWait);
|
|
11086
11341
|
logger.info(`Submitting to Replicate ${describeModelRef(model)}...`);
|
|
11087
|
-
const prediction2 = await client.createPrediction({
|
|
11342
|
+
const prediction2 = await client.createPrediction({
|
|
11343
|
+
model,
|
|
11344
|
+
input,
|
|
11345
|
+
preferWaitSeconds: opts.preferWait
|
|
11346
|
+
});
|
|
11088
11347
|
if (opts.async) {
|
|
11089
11348
|
printAsyncHandle(prediction2);
|
|
11090
11349
|
return;
|
|
@@ -11156,7 +11415,7 @@ import fs45 from "fs";
|
|
|
11156
11415
|
import path52 from "path";
|
|
11157
11416
|
var DATA_URI_MAX_BYTES = 256 * 1024;
|
|
11158
11417
|
var UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
|
11159
|
-
function
|
|
11418
|
+
function formatMb3(bytes) {
|
|
11160
11419
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
11161
11420
|
}
|
|
11162
11421
|
async function resolveFileInput(value, client, logger) {
|
|
@@ -11172,7 +11431,7 @@ async function resolveFileInput(value, client, logger) {
|
|
|
11172
11431
|
if (stat.size === 0) throw new ValidationError(`Input file is empty: ${value}`);
|
|
11173
11432
|
if (stat.size >= UPLOAD_MAX_BYTES) {
|
|
11174
11433
|
throw new ValidationError(
|
|
11175
|
-
`Input file ${value} is ${
|
|
11434
|
+
`Input file ${value} is ${formatMb3(stat.size)}; Replicate accepts uploads under 100MB. Host it and pass an https URL instead.`
|
|
11176
11435
|
);
|
|
11177
11436
|
}
|
|
11178
11437
|
const filename = path52.basename(filePath);
|
|
@@ -11182,7 +11441,7 @@ async function resolveFileInput(value, client, logger) {
|
|
|
11182
11441
|
logger.debug(`Inlining ${filename} (${stat.size} bytes) as a data URI`);
|
|
11183
11442
|
return `data:${contentType};base64,${bytes.toString("base64")}`;
|
|
11184
11443
|
}
|
|
11185
|
-
logger.info(`Uploading ${filename} (${
|
|
11444
|
+
logger.info(`Uploading ${filename} (${formatMb3(stat.size)}) to Replicate Files...`);
|
|
11186
11445
|
const file = await client.uploadFile({
|
|
11187
11446
|
data: new Blob([bytes], { type: contentType }),
|
|
11188
11447
|
filename,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrgoonie/multix",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "AI multimodal CLI — OpenAI, Gemini, MiniMax, OpenRouter, Leonardo, BytePlus, ElevenLabs, fal.ai, Atlas Cloud, Replicate, HeyGen, ffmpeg, ImageMagick, doc-to-md",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skill/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: multix
|
|
3
3
|
description: AI multimodal CLI — generate and edit images (OpenAI, Codex driver, Gemini Nano Banana, Imagen, MiniMax, OpenRouter, Leonardo, BytePlus Seedream, Cloudflare Workers AI FLUX.1 Schnell, fal.ai FLUX, Replicate FLUX), generate video (Gemini Veo, MiniMax Hailuo, OpenRouter via Veo/Seedance/Kling, Leonardo via Motion/Veo/Kling/Hailuo/Seedance/LTX, BytePlus Seedance, Cloudflare AI Gateway via Replicate, fal.ai queue models, Replicate direct API models incl. P-Video, HeyGen Video Agent and scripted avatar videos), video translation and lipsync (HeyGen), TTS/STT/music (OpenAI, Gemini Flash TTS incl. Gemini 3.8 with delivery-style control, MiniMax, Cloudflare Workers AI MeloTTS, ElevenLabs), 3D models (BytePlus Hyper3D/Hitem3d), analyze/transcribe media, convert documents to Markdown, optimize media via ffmpeg/ImageMagick, and self-update the CLI. Use whenever the user wants to create, edit, or transform images/video/audio/3D/documents from a CLI, mentions any of: OpenAI, Codex image driver, image-to-image, i2i, image edit, watercolor/cyberpunk style transfer, reference image, OpenRouter, Nano Banana, Flux, Seedream, Veo, Hailuo, Seedance, Kling, Cloudflare Workers AI, AI Gateway, fal.ai, Atlas Cloud, Replicate, replicate.com models, Replicate predictions, HeyGen, avatar video, talking head, Video Agent, video translation, dubbing, lipsync, video generation, text-to-video, image-to-video, i2v, ElevenLabs voice cloning, updating/upgrading multix, or asks for batch media optimization.
|
|
4
|
-
version: 0.14.
|
|
4
|
+
version: 0.14.1 # x-release-please-version
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# multix — AI Multimodal CLI
|
|
@@ -178,7 +178,9 @@ multix replicate status <prediction-id> --wait --download
|
|
|
178
178
|
multix replicate cancel <prediction-id>
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
Shared flags: `--async`, `--wait`, `--prefer-wait [seconds]` (sends `Prefer: wait=N`, still polls if unfinished), `--wait-timeout <ms>`, `--no-download` (print URLs), `--output <path|dir/>`. `failed`/`canceled`/`aborted` predictions exit non-zero. Local files up to 256 KB are inlined as data URIs; larger files (< 100 MB) go through the Replicate Files API.
|
|
181
|
+
Shared flags: `--async`, `--wait`, `--prefer-wait [seconds]` (integer 1-60, bare flag = 60; sends `Prefer: wait=N`, still polls if unfinished; put the prompt before a bare flag), `--wait-timeout <ms>`, `--no-download` (print URLs), `--output <path|dir/>`. `failed`/`canceled`/`aborted` predictions exit non-zero. Local files up to 256 KB are inlined as data URIs; larger files (< 100 MB) go through the Replicate Files API.
|
|
182
|
+
|
|
183
|
+
Output downloads are `https://` only; `localhost` and loopback/private/link-local/special-purpose IP literals (including redirects; hostnames are not resolved) are refused, non-`replicate.delivery` hosts are downloaded with a warning, and each file is capped at `MULTIX_MAX_DOWNLOAD_MB` (default 2048). If submitting fails with a network error, timeout, 5xx, or an unreadable 2xx response, the prediction may still exist and bill: check https://replicate.com/predictions before retrying instead of resubmitting blindly.
|
|
182
184
|
|
|
183
185
|
## HeyGen
|
|
184
186
|
|
|
@@ -384,6 +386,7 @@ Requires `cwebp` (libwebp: `brew install webp`, `apt install webp`) on `PATH`.
|
|
|
384
386
|
| Variable | Purpose |
|
|
385
387
|
|---|---|
|
|
386
388
|
| `MULTIX_OUTPUT_DIR` | Override default output dir |
|
|
389
|
+
| `MULTIX_MAX_DOWNLOAD_MB` | Size limit per downloaded Replicate output file (default 2048) |
|
|
387
390
|
| `OPENAI_IMAGE_MODEL` / `OPENAI_TTS_MODEL` / `OPENAI_STT_MODEL` | OpenAI model defaults |
|
|
388
391
|
| `OPENROUTER_IMAGE_MODEL` | Default OpenRouter image model |
|
|
389
392
|
| `OPENROUTER_FALLBACK_MODELS` | CSV; applies to `generate` AND `i2i` |
|